คลัง
web

Race Condition

Race Condition เกิดเมื่อหลาย request ที่ส่งพร้อมกันเข้าถึง/แก้ไขสถานะเดียวกันในช่วงเวลาที่แอปยังตรวจสอบไม่เสร็จ ทำให้ตรรกะที่ควรผ่านครั้งเดียวถูกผ่านหลายครั้ง เช่น ใช้คูปองซ้ำ ถอนเงินเกินยอด รีดีมของรางวัลเกินโควตา บทนี้ไล่ตั้งแต่หลักการ TOCTOU, การหา sub-state จริง, single-packet attack, limit-overrun, ไปจนถึง multi-endpoint race และการป้องกัน

Advanced#race-condition#toctou#concurrency#limit-overrun#single-packet#web#ctf

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 นี้ได้เกือบหมด

TOCTOU — request หลายตัวอ่าน state ก่อน commit
state: coupon.used = false (balance = 1) req #1 req #2 req #3 collision window CHECK used==false (ยังไม่ commit) ทุก req เห็น false USE x3 !! ผลลัพธ์: คูปอง 1 ใบถูกใช้ 3 ครั้ง (limit-overrun) — ควรได้แค่ 1
Race condition เป็น subset ของ business logic flaw — สิ่งที่โจมตีไม่ใช่ bug ในโค้ด input validation แต่เป็นสมมติฐานว่า 'action นี้เกิดทีละครั้ง' อ่านเสริมได้ที่หัวข้อ Business Logic

2. ประเภทของ race และเป้าหมายที่พบบ่อย

ประเภทรูปแบบตัวอย่างผลกระทบ
Limit overrunaction มีเพดาน แต่ตรวจ+หัก ไม่ atomicใช้คูปอง/gift card ซ้ำ, ถอนเงินเกินยอด
Single-endpoint raceยิง endpoint เดียวซ้ำพร้อมกันvote ซ้ำ, like ซ้ำ, rate-limit bypass OTP
Multi-endpoint raceยิงหลาย endpoint คนละแบบพร้อมกันเพิ่มของลงตะกร้าระหว่างจ่ายเงิน (จ่ายราคาเก่า)
Time-sensitivetoken/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. 1หา endpoint ที่ทำ action มีเพดาน/ใช้ครั้งเดียว (apply coupon, redeem, withdraw, vote, verify OTP)
  2. 2หา action ที่แก้ไข shared resource แล้วมีผลข้าม request (balance, quota, status flag)
  3. 3ทดสอบ baseline: ยิงทีละ request ดูว่าทำสำเร็จได้กี่ครั้งตามปกติ (ควรได้ 1)
  4. 4ยิงชุด 20-50 request พร้อมกันด้วย single-packet แล้วนับว่าสำเร็จเกิน 1 หรือไม่
  5. 5สังเกต response ที่ 'ผิดปกติ': หลาย 200 OK ที่ควรได้แค่อันเดียว, ยอดคงเหลือติดลบ, duplicate ID
  6. 6ถ้าไม่ติดครั้งแรก ยิงซ้ำหลายรอบ — race ขึ้นกับ timing และ load ของ DB
สัญญาณของ collision window ที่แคบ: ลอง connection warming (ยิง request ธรรมดา 1 ตัวก่อน เพื่อเปิด TCP/TLS ให้พร้อม) แล้วค่อยปล่อยชุด race — ลด server-side jitter จากการ setup connection

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

Turbo Intruder — single-packet attack (HTTP/2)
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)
engine=Engine.BURP2 + gate เดียว = single-packet บน HTTP/2; ถ้า target เป็น HTTP/1.1 Turbo Intruder จะ fallback เป็น last-byte sync ให้อัตโนมัติ
Turbo Intruder — race หลาย payload (เช่น brute OTP ใน window เดียว)
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)
ใช้เมื่อระบบล็อกบัญชีหลังลองผิด N ครั้ง แต่ counter เพิ่มไม่ atomic — ยิงทุก OTP พร้อมกันจะผ่าน rate-limit check ก่อนถูกล็อก
Burp Repeater เวอร์ชันใหม่มีปุ่ม Send group in parallel (single-packet attack) — จัด request หลายตัวเป็น tab group แล้วส่งพร้อมกันได้โดยไม่ต้องเขียนสคริปต์ เหมาะกับ multi-endpoint race

5. Limit-overrun และ Multi-endpoint race

Limit-overrun คือรูปแบบที่พบบ่อยสุด: ยิง request เดียวกัน N ครั้งพร้อมกันเพื่อทะลุเพดาน ส่วน multi-endpoint race ยิง request คนละชนิดพร้อมกันเพื่อใช้ประโยชน์จาก state ที่ยังไม่ sync — เช่น เพิ่มสินค้าราคาแพงลงตะกร้า ระหว่าง ที่ระบบกำลัง confirm การจ่ายเงินของตะกร้าราคาถูก

HTTP ตัวอย่าง — limit-overrun apply gift card
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 ครั้ง = 30000
ตรวจว่าโจทย์ให้เพดานอะไร (1 ครั้ง/ใบ) แล้วนับ response ที่สำเร็จเกินเพดาน
Decision flow — เจอ endpoint สงสัย race
endpoint นี้มี action ที่ควรทำได้ครั้งเดียว/มีเพดานไหม?
redeem, withdraw, vote, verify
state ถูก share ข้าม request ไหม?
ใช่ (balance/quota/flag)→ ลอง single-endpoint race
ต้องประสาน 2 action→ multi-endpoint race
warm connection + queue 20-40 req เข้า gate เดียว
Turbo Intruder Engine.BURP2
openGate — ปล่อย single-packet พร้อมกัน
นับ response สำเร็จ
สำเร็จ > 1 (เกินเพดาน)race ยืนยัน → exploit ต่อ
สำเร็จ = 1ยิงซ้ำหลายรอบ / เพิ่มจำนวน / warm ก่อน

6. Walkthrough — CTF & real-world

สถานการณ์ (PortSwigger-style): ร้านมีคูปอง PROMO20 ลด 20% ใช้ได้ครั้งเดียว/ออเดอร์ ต้องการลดหลายชั้นจนราคาเหลือ 0

  1. 1ใส่สินค้าในตะกร้า จับ request POST /cart/coupon ที่ apply คูปอง
  2. 2ยิงทีละครั้งเห็นว่าใช้ซ้ำไม่ได้ (server ตอบ 'coupon already applied')
  3. 3ส่ง POST /cart/coupon ไป Turbo Intruder, queue 20 request เข้า gate เดียว
  4. 4warm connection ด้วย GET / 1 ครั้งก่อน openGate เพื่อลด setup jitter
  5. 5openGate — สังเกตว่ามีหลาย response ที่ apply สำเร็จ (เกิน 1)
  6. 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 ติดลบ
ป้องกันด้วย atomic UPDATE (SQL)
-- ผิด: 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. 1เปิด Burp Suite, ตั้ง proxy บนเบราว์เซอร์ แล้วทำ action ที่สงสัย (เช่นกด apply coupon) จับ request ไว้ใน Proxy history
  2. 2ส่ง request นั้นไป Repeater แล้วกด Send ทีละครั้งดูว่าปกติทำสำเร็จได้กี่ครั้ง (baseline ควรได้ 1 ครั้ง)
  3. 3ติดตั้ง Turbo Intruder ฟรีจาก BApp Store: Burp → Extensions → BApp Store → ค้นหา 'Turbo Intruder' → Install
  4. 4คลิกขวา request ใน Proxy history → Extensions → Turbo Intruder → Send to Turbo Intruder
  5. 5แก้สคริปต์ Python ที่เปิดมา ให้ queue request 20-30 ตัวเข้า gate เดียวด้วย engine=Engine.BURP2
  6. 6เพิ่ม warm connection: ยิง GET ธรรมดา 1 ครั้งก่อน แล้วค่อย engine.openGate('race1')
  7. 7กด Attack แล้วดูตาราง result — นับว่ามีกี่ response ที่สำเร็จ (200/2xx หรือข้อความ 'applied')
  8. 8ถ้าสำเร็จเกิน 1 ครั้ง ให้ตรวจผลจริงในแอป (ยอดคูปอง/balance) ว่าถูกใช้ซ้ำจริง
  9. 9ถ้ายังไม่ติด ลองเพิ่มจำนวน request เป็น 50-100 และรันซ้ำหลายรอบ (race เป็น probabilistic)
  10. 10ถ้า target เป็น HTTP/1.1 ล้วน Turbo Intruder จะ fallback เป็น last-byte sync ให้เองอัตโนมัติ
Decision flow — จับมือทำ Race Condition
หา endpoint ที่ควรทำได้ครั้งเดียว/มีเพดาน
redeem, withdraw, vote, verify OTP
state ถูก share ข้าม request ไหม (balance/quota/flag)?
✅ ใช่ มี shared state→ วัด baseline ก่อนยิง race
❌ ไม่มี ทำซ้ำได้ปกติอยู่แล้ว→ ไม่ใช่ race ให้ดูเป็น business logic ทั่วไป
ยิงทีละ request ด้วย Burp Repeater นับว่าสำเร็จกี่ครั้ง
baseline ควรได้ 1
ติดตั้ง Turbo Intruder แล้วส่ง request เข้าไป queue 20-30 ตัว gate เดียว
engine=Engine.BURP2
warm connection ด้วย GET ธรรมดา แล้ว openGate ปล่อยพร้อมกัน
นับ response ที่สำเร็จ
✅ สำเร็จ > 1 (เกินเพดาน)→ ยืนยัน race แล้ว ยกระดับ
❌ สำเร็จ = 1 เท่าเดิม→ เพิ่มจำนวน request / ทำซ้ำหลายรอบ / เช็ค HTTP version
ขั้นตอน/งานเครื่องมือใน 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 เองpython3pip install requests-
ดู/วัด response time และผลลัพธ์Burp Logger / Repeater--
🚑 ถ้าตันสนิท ลองท่าถัดไป: Business Logic Flaw — ถ้าไม่มี TOCTOU จริง แต่ยังสงสัยว่า flow มีจุดโกงได้ ให้มองเป็นปัญหา logic กว้างๆ; GraphQL Attacks — ถ้า endpoint เป็น GraphQL mutation ลอง alias batching brute แทนการ race ตรงๆ; IDOR — ถ้าตอน race เจอว่า response โชว์ข้อมูล/แก้ไขของบัญชีอื่นได้ด้วย

หัวข้อที่เชื่อมโยง

โน้ตของฉัน

ยังไม่มีโน้ตสำหรับหัวข้อนี้