Vulnerability Detail Report
Vulnerability Overview
- ZDID: ZD-2026-00804
- Vendor: 中部高中電資社團聯合會議
- Title: 中部高中電資社團聯合會議 SCAICT-uwu /buyProduct race can return two successful purchases while persisting one point/stock decrement
- Introduction: The public SCAICT-uwu /buyProduct route can return two successful local purchase responses for one-stock inventory while only one point/stock decrement is persisted.
處理狀態
目前狀態
-
新提交
-
已審核
-
已通報
-
未回報修補狀況
-
未複測
-
公開
處理歷程
- 2026/06/03 19:40:26 : 新提交 (由 老狼 更新此狀態)
- 2026/06/10 12:46:32 : 審核中 (由 HITCON ZeroDay 服務團隊 更新此狀態)
- 2026/07/21 09:25:26 : 修補中 (由 組織帳號 更新此狀態)
- 2026/08/03 03:00:03 : 公開 (由 HITCON ZeroDay 平台自動更新)
詳細資料
- ZDID:ZD-2026-00804
- 通報者:skyknow (老狼)
- 風險:中
- 類型:邏輯漏洞 (Logic Flaws)
參考資料
漏洞說明: OWASP - Testing for business logic
https://www.owasp.org/index.php/Testing_for_business_logic
漏洞說明: CWE-840: Business Logic Errors
https://cwe.mitre.org/data/definitions/840.html
相關網址
https://scaict.org/
https://github.com/SCAICT/SCAICT-uwu
https://github.com/SCAICT/SCAICT-uwu/blob/ef9174f4893895083465af21b3c00aa36981a9d8/app.py#L368-L405
https://scaict.org/buyProduct
敘述
Summary
The public SCAICT/SCAICT-uwu source implements POST /buyProduct as a read-check-write purchase flow across two mutable stores: user points in SQL and product stock in database/products.json.
The handler checks stock and points from request-local snapshots, writes an absolute updated point value, decrements the in-memory product stock, rewrites the whole JSON file, and returns 購買成功!. It does not appear to use a transaction, row lock, file lock, conditional decrement, stock reservation row, or idempotency key.
In a local-only harness that imports the real public app.py route and replaces only external services with local fakes, two overlapping local HTTP POST requests to /buyProduct both returned 購買成功! for a one-stock product, while the final synthetic point and stock state reflected only one decrement.
I did not contact SCAICT production, use a real account/session, connect to a real database, call Discord/GitHub APIs, or perform any real product purchase.
Technical details
In app.py, /buyProduct performs this sequence:
line 368: @app.route("/buyProduct", methods=["POST"])
line 371: discord_user = session.get("user")
line 377: open database/products.json
line 380: select product by id
line 383: reject only if product["stock"] < 1
line 394: user_points = read(discord_user["id"], "point", cursor)
line 395: reject only if user_points < product["price"]
line 398: user_points -= product["price"]
line 400: write(discord_user["id"], "point", user_points, cursor)
line 402: product["stock"] -= 1
line 403: rewrite database/products.json
line 405: return "購買成功!"
Because the route reads stock and points before writing absolute updated values, two overlapping requests can both pass the same initial checks. If both requests read point=50 and stock=1, both can compute and write the same final point=0 and stock=0, while both requests return success.
The public source did not show a transaction boundary, atomic SQL decrement, conditional stock update, file lock, idempotency token, or order/reservation row around this purchase path.
Local validation
Command:
python3 findings/20260603-0038-hitcon-scaict-buyproduct-lost-update/local-repro.py
The strongest harness imports the real public app.py route from the local clone, replaces only external service modules with local fakes, uses a temporary database/products.json, creates synthetic sessions through a local-only helper route, and sends two HTTP POST requests to the real imported /buyProduct route through a temporary threaded Werkzeug server bound to 127.0.0.1.
Observed local result:
{
"actual_app_py_route_loopback_harness": {
"bind_address": "127.0.0.1",
"external_network_requests": 0,
"final_points": 0,
"final_stock": 0,
"imported_route_source": "/tmp/hitcon-active/SCAICT-uwu/app.py",
"loopback_request_counts": {"buy_product_posts": 2, "set_session": 2},
"lost_point_decrement": true,
"lost_stock_decrement": true,
"responses": [
{"body": "購買成功!", "status_code": 200},
{"body": "購買成功!", "status_code": 200}
],
"success_count": 2,
"uses_fake_sql_modules_only": true,
"uses_real_imported_app_py_route": true,
"uses_tempfile_products_json_only": true,
"write_calls": [
{"column": "point", "uid": "12345", "value": 0},
{"column": "point", "uid": "12345", "value": 0}
]
}
}
This local result shows two successful purchase responses, but the final synthetic point/stock state only reflects one decrement.
Impact
Public source gives this a practical store-accounting impact:
- The README says users can buy products such as stickers or USB drives with Electric Points, and that products currently can only be exchanged during in-person events.
database/products.jsonlists finite-stock physical merchandise, including stickers, notebook, and an8 GB USB.templates/home.htmlwarns users to click redeem only after receiving the physical item at an event, otherwise the prize will not be reissued.
If the deployed service processes overlapping requests for this route and operators honor the successful purchase state, the race can create two successful redemption outcomes while charging and reserving inventory only once.
This report does not claim that I exploited the production service, completed a real purchase, obtained merchandise, accessed other users' data, or proved the exact production web-server concurrency model.
Safety boundary
- No SCAICT production request.
- No real account, session, SQL database, product purchase, Discord API, GitHub API, user data, student data, or third-party data.
- No scanner, fuzzing, DoS, brute force, social engineering, vendor contact, public issue/PR, or production interaction.
- Validation used only public source, local fake service modules, temporary fixture data, and loopback HTTP bound to
127.0.0.1.
修補建議
Move user point decrement and product stock decrement into one atomic server-side transaction. Store product stock in SQL or another transactional store instead of rewriting a shared JSON file for concurrent purchase state. Use conditional updates such as stock = stock - 1 WHERE stock > 0 and point = point - price WHERE point >= price. Commit only when both point and stock updates succeed. Add a purchase/order table with a unique idempotency token per purchase attempt, and return 購買成功! only after exactly one durable purchase/order record is committed.