Race Condition
Race Condition เกิดเมื่อหลาย request ที่ส่งพร้อมกันเข้าถึง/แก้ไขสถานะเดียวกันในช่วงเวลาที่แอปยังตรวจสอบไม่เสร็จ ทำให้ตรรกะที่ควรผ่านครั้งเดียวถูกผ่านหลายครั้ง เช่น ใช้คูปองซ้ำ ถอนเงินเกินยอด รีดีมของรางวัลเกินโควตา บทนี้ไล่ตั้งแต่หลักการ TOCTOU, การหา sub-state จริง, single-packet attack, limit-overrun, ไปจนถึง multi-endpoint race และการป้องกัน
1. หลักการ — TOCTOU และ collision window
ช่องโหว่เกิดจากช่องว่างระหว่าง เวลาตรวจสอบ (Time-Of-Check) กับ เวลาใช้งาน (Time-Of-Use) — เรียกว่า TOCTOU ถ้าส่งหลาย request พร้อมกันพอ ทุก request จะอ่านค่าสถานะ (state) ตัวเดียวกันก่อนที่ request แรกจะเขียนค่าใหม่กลับไป ทำให้ทั้งหมด 'เห็นว่ายังทำได้' และทำ action ซ้ำหลายครั้ง ตัวอย่างคลาสสิก: อ่านยอดคูปอง → เช็คว่ายังไม่ถูกใช้ → หักยอด/ทำเครื่องหมายว่าใช้แล้ว หากขั้นตอนเหล่านี้ไม่เป็น atomic transaction เดียว จะเกิด race
หัวใจของการโจมตีคือทำให้ request ทุกตัวตกลงในสิ่งที่ PortSwigger เรียกว่า collision window — ช่วงเวลาสั้นๆ ที่ state ยังไม่ถูก commit เดิมเราต้องสู้กับ network jitter (แต่ละ request เดินทางไม่พร้อมกัน) เทคนิคสมัยใหม่ (single-packet attack) กำจัด jitter นี้ได้เกือบหมด
2. ประเภทของ race และเป้าหมายที่พบบ่อย
| ประเภท | รูปแบบ | ตัวอย่างผลกระทบ |
|---|---|---|
| Limit overrun | action มีเพดาน แต่ตรวจ+หัก ไม่ atomic | ใช้คูปอง/gift card ซ้ำ, ถอนเงินเกินยอด |
| Single-endpoint race | ยิง endpoint เดียวซ้ำพร้อมกัน | vote ซ้ำ, like ซ้ำ, rate-limit bypass OTP |
| Multi-endpoint race | ยิงหลาย endpoint คนละแบบพร้อมกัน | เพิ่มของลงตะกร้าระหว่างจ่ายเงิน (จ่ายราคาเก่า) |
| Time-sensitive | token/nonce สร้างจาก timestamp เดียวกัน | predict/collide token, reuse nonce |
| Partial construction | อ่าน object ระหว่างที่ยังสร้างไม่เสร็จ | อ่าน record ที่ยังไม่ตั้งค่า permission |
- ใช้คูปอง / gift card / promo code ซ้ำหลายครั้ง (limit overrun ยอดนิยมใน CTF)
- ถอน/โอนเงินเกินยอดคงเหลือ (double spend)
- redeem รางวัล/โควตาเกินกำหนด (เช่น จำกัด 1 คน/บัญชี)
- bypass rate limit ของ OTP / login / 2FA (ยิง verify หลายครั้งใน window เดียว)
- เปลี่ยน email/reset token ให้ผ่านหลายบัญชี
- เพิ่มยอดเงิน/แต้มด้วยการ transfer ไป-กลับพร้อมกัน
3. การตรวจจับ — หา sub-state ที่ race ได้
ไม่ใช่ทุก endpoint ที่ race ได้ ต้องมองหาจุดที่มี hidden multi-step process ภายใน request เดียว (อ่าน → ตัดสินใจ → เขียน) แนวคิด 'predict the sub-state' ของ PortSwigger: หา operation ที่ทำงานหลายขั้นบน state ร่วมกัน
- 1หา endpoint ที่ทำ action มีเพดาน/ใช้ครั้งเดียว (apply coupon, redeem, withdraw, vote, verify OTP)
- 2หา action ที่แก้ไข shared resource แล้วมีผลข้าม request (balance, quota, status flag)
- 3ทดสอบ baseline: ยิงทีละ request ดูว่าทำสำเร็จได้กี่ครั้งตามปกติ (ควรได้ 1)
- 4ยิงชุด 20-50 request พร้อมกันด้วย single-packet แล้วนับว่าสำเร็จเกิน 1 หรือไม่
- 5สังเกต response ที่ 'ผิดปกติ': หลาย 200 OK ที่ควรได้แค่อันเดียว, ยอดคงเหลือติดลบ, duplicate ID
- 6ถ้าไม่ติดครั้งแรก ยิงซ้ำหลายรอบ — race ขึ้นกับ timing และ load ของ DB
4. Single-Packet Attack — กำจัด network jitter
เทคนิคเดิม (last-byte sync) ส่งเกือบทั้ง request ไปก่อน เหลือ byte สุดท้ายไว้ แล้วปล่อย byte สุดท้ายของทุก request พร้อมกัน แต่ยังมี jitter จากหลาย TCP packet เทคนิคใหม่ single-packet attack (James Kettle, 2023) บรรจุ 20-30 request ให้อยู่ใน TCP packet เดียว ผ่าน HTTP/2 multiplexing ทำให้เซิร์ฟเวอร์ได้รับทั้งหมดพร้อมกันจริง กำจัด jitter เครือข่ายได้เกือบหมด — collision window เหลือแค่ระดับ server processing
def queueRequests(target, wordlists):
# engine = HTTP2 พร้อม single-packet
engine = RequestEngine(
endpoint=target.endpoint,
concurrentConnections=1,
engine=Engine.BURP2
)
# คิว 30 request เหมือนกันทั้งหมด เข้า gate เดียว
for i in range(30):
engine.queue(target.req, gate='race1')
# ปล่อยทุก request พร้อมกันใน packet เดียว
engine.openGate('race1')
def handleResponse(req, interesting):
table.add(req)def queueRequests(target, wordlists):
engine = RequestEngine(
endpoint=target.endpoint,
concurrentConnections=1,
engine=Engine.BURP2
)
# %s ใน target.req จะถูกแทนด้วยแต่ละคำใน wordlist (เช่น OTP 0000-9999)
for otp in open('/path/otps.txt'):
engine.queue(target.req, otp.strip(), gate='race1')
engine.openGate('race1')
def handleResponse(req, interesting):
if '2FA verified' in req.response or req.status != 401:
table.add(req)5. Limit-overrun และ Multi-endpoint race
Limit-overrun คือรูปแบบที่พบบ่อยสุด: ยิง request เดียวกัน N ครั้งพร้อมกันเพื่อทะลุเพดาน ส่วน multi-endpoint race ยิง request คนละชนิดพร้อมกันเพื่อใช้ประโยชน์จาก state ที่ยังไม่ sync — เช่น เพิ่มสินค้าราคาแพงลงตะกร้า ระหว่าง ที่ระบบกำลัง confirm การจ่ายเงินของตะกร้าราคาถูก
POST /api/wallet/redeem HTTP/2
Host: shop.example
Cookie: session=USER_SESSION
Content-Type: application/json
{"code":"GIFT-1000-THB"}
# ยิง request นี้ 30 ตัวพร้อมกัน (single-packet)
# ถ้า redeem ไม่ atomic: gift card 1000 บาท ถูกเครดิต 30 ครั้ง = 300006. Walkthrough — CTF & real-world
สถานการณ์ (PortSwigger-style): ร้านมีคูปอง PROMO20 ลด 20% ใช้ได้ครั้งเดียว/ออเดอร์ ต้องการลดหลายชั้นจนราคาเหลือ 0
- 1ใส่สินค้าในตะกร้า จับ request POST /cart/coupon ที่ apply คูปอง
- 2ยิงทีละครั้งเห็นว่าใช้ซ้ำไม่ได้ (server ตอบ 'coupon already applied')
- 3ส่ง POST /cart/coupon ไป Turbo Intruder, queue 20 request เข้า gate เดียว
- 4warm connection ด้วย GET / 1 ครั้งก่อน openGate เพื่อลด setup jitter
- 5openGate — สังเกตว่ามีหลาย response ที่ apply สำเร็จ (เกิน 1)
- 6ราคาถูกลด 20% หลายชั้นซ้อนกันจนติดลบ/เหลือ 0 → checkout
Real-world: บั๊กประเภทนี้ถูกรายงานจริงบนแพลตฟอร์มการเงิน/e-wallet หลายเจ้า เช่น การถอนเงินพร้อมกันหลาย request ทำให้ยอดคงเหลือติดลบ (double withdrawal) และการ transfer แต้ม/เงินระหว่างสองบัญชีของตัวเองพร้อมกันจนยอดรวมเพิ่มขึ้น — ผลกระทบระดับ financial loss
7. ข้อผิดพลาดที่พบบ่อย & troubleshooting
- ยิงด้วย Intruder ธรรมดา (sequential): ไม่ใช่ parallel จริง — ต้องใช้ Turbo Intruder gate หรือ Repeater parallel
- ไม่ warm connection: request แรกช้าเพราะ TLS handshake → ตกนอก window ลอง warm ก่อน
- จำนวน request น้อยไป: เพิ่มเป็น 30-100 เพื่อเพิ่มโอกาสชน window
- ยิงรอบเดียวแล้วยอมแพ้: race เป็น probabilistic ยิงซ้ำ 5-10 รอบ
- ลืมว่า HTTP/1.1 ต้อง last-byte sync: ถ้า target ไม่รองรับ HTTP/2 อย่าคาดหวัง single-packet
- state reset ระหว่างทดสอบ: บางโจทย์ reset คูปอง/balance ต่อรอบ ตรวจว่า test แต่ละรอบเริ่มจาก state สะอาด
- rate-limit / WAF ตัด connection: ลดจำนวน หรือกระจายเวลา warm ให้เนียน
8. การป้องกันและตรวจจับฝั่ง defense
- Atomic operation: รวม check+use ไว้ใน transaction เดียว ใช้
SELECT ... FOR UPDATEหรือ optimistic locking (version column) - Database constraint: unique index บน (user_id, coupon_id) ให้ DB ปฏิเสธ insert ซ้ำเอง
- Idempotency key: ให้ client ส่ง key ต่อ operation server รับ key ซ้ำแค่ครั้งเดียว
- Distributed lock: Redis SETNX / Redlock ล็อกที่ระดับ resource ก่อนทำ action
- อย่าพึ่ง read-then-write ใน app code: ใช้ atomic increment/decrement ของ DB (เช่น UPDATE balance = balance - 1 WHERE balance >= 1)
- Detection: ตรวจ log ที่มี request ซ้ำจำนวนมากในเสี้ยววินาทีจาก session/IP เดียว, alert เมื่อ balance ติดลบ
-- ผิด: race ได้ (read → check ใน app → write)
SELECT balance FROM wallet WHERE id = 1; -- app เช็ค >= 1
UPDATE wallet SET balance = balance - 1 WHERE id = 1;
-- ถูก: atomic — เงื่อนไขอยู่ใน UPDATE เดียว DB การันตี
UPDATE wallet SET balance = balance - 1
WHERE id = 1 AND balance >= 1;
-- ถ้า affected rows = 0 แปลว่าไม่มียอดพอ (ทุก request แข่งกันที่ row lock)9. Quick Reference
- TOCTOU: ช่องว่างระหว่าง check กับ use → collision window
- เป้าหมาย: คูปอง/gift card ซ้ำ, ถอนเกินยอด, bypass OTP rate-limit
- กุญแจ: ยิงหลาย request ให้ถึงพร้อมกันที่สุด (ลด jitter)
- เครื่องมือ: Turbo Intruder + gate (Engine.BURP2) = single-packet
- Burp Repeater: Send group in parallel (single-packet)
- warm connection ก่อน openGate; ยิง 20-100 req; ทำหลายรอบ
- HTTP/2 → single-packet; HTTP/1.1 → last-byte sync
- ป้องกัน: atomic UPDATE, unique constraint, idempotency key, distributed lock
🧭 จับมือทำทีละขั้น (มีแค่ Kali) + ถ้าติดไปไหนต่อ
สมมติเจอ endpoint ที่ทำ action ครั้งเดียว/มีเพดาน (redeem coupon, withdraw, vote, verify OTP) มีแค่ Kali เปล่าๆ ทำตามนี้ทีละขั้น
- 1เปิด Burp Suite, ตั้ง proxy บนเบราว์เซอร์ แล้วทำ action ที่สงสัย (เช่นกด apply coupon) จับ request ไว้ใน Proxy history
- 2ส่ง request นั้นไป Repeater แล้วกด Send ทีละครั้งดูว่าปกติทำสำเร็จได้กี่ครั้ง (baseline ควรได้ 1 ครั้ง)
- 3ติดตั้ง Turbo Intruder ฟรีจาก BApp Store: Burp → Extensions → BApp Store → ค้นหา 'Turbo Intruder' → Install
- 4คลิกขวา request ใน Proxy history → Extensions → Turbo Intruder → Send to Turbo Intruder
- 5แก้สคริปต์ Python ที่เปิดมา ให้ queue request 20-30 ตัวเข้า gate เดียวด้วย engine=Engine.BURP2
- 6เพิ่ม warm connection: ยิง GET ธรรมดา 1 ครั้งก่อน แล้วค่อย engine.openGate('race1')
- 7กด Attack แล้วดูตาราง result — นับว่ามีกี่ response ที่สำเร็จ (200/2xx หรือข้อความ 'applied')
- 8ถ้าสำเร็จเกิน 1 ครั้ง ให้ตรวจผลจริงในแอป (ยอดคูปอง/balance) ว่าถูกใช้ซ้ำจริง
- 9ถ้ายังไม่ติด ลองเพิ่มจำนวน request เป็น 50-100 และรันซ้ำหลายรอบ (race เป็น probabilistic)
- 10ถ้า target เป็น HTTP/1.1 ล้วน Turbo Intruder จะ fallback เป็น last-byte sync ให้เองอัตโนมัติ
| ขั้นตอน/งาน | เครื่องมือใน Kali | ติดตั้งเพิ่ม (ถ้าไม่มี) | เครื่องมือออนไลน์ |
|---|---|---|---|
| Proxy/Repeater หลัก | Burp Suite Community | - | - |
| ยิง single-packet race | - | Turbo Intruder (BApp Store, ฟรี) | - |
| ยิง parallel เร็วๆ แบบ CLI (สำรอง) | curl (รันหลาย process พร้อมกัน &) | - | - |
| ทดสอบ HTTP/2 multiplex ด้วยมือ | - | nghttp2-client (apt install nghttp2-client) | - |
| เขียน/แก้สคริปต์ race เอง | python3 | pip install requests | - |
| ดู/วัด response time และผลลัพธ์ | Burp Logger / Repeater | - | - |
หัวข้อที่เชื่อมโยง
โน้ตของฉัน
ยังไม่มีโน้ตสำหรับหัวข้อนี้