
CVE-2026-49268 — تحليل ومعالجة ثغرة تجاوز المصادقة عبر حقن LDAP
| الفرع | 1.13-CVE-2026-49268 |
| المؤلف | Jinwoo Hwang (https://JinwooHwang.com) |
يحتوي هذا الفرع (1.13-CVE-2026-49268) على معالجة أمنية شاملة لثغرة تجاوز المصادقة عبر حقن LDAP في إصدار Apache Shiro 1.13.
وصف NVD الرسمي
يمكن لمهاجم عن بُعد حقن أحرف LDAP الخاصة في بناء Distinguished Name (DN) في فئة DefaultLdapRealm. يتم ربط إدخال اسم المستخدم الذي يوفره المستخدم مباشرةً في قالب LDAP DN دون أي تهريب لأحرف RFC 2253 الخاصة. يتيح ذلك للمهاجم التلاعب ببنية DN المستخدمة في مصادقة ربط LDAP، مما قد يؤدي إلى تجاوز المصادقة أو انتحال هوية مستخدمين آخرين. تؤثر هذه المشكلة على جميع إصدارات Apache Shiro حتى 2.2.0، و3.0.0-alpha-1 عند استخدام DefaultLdapRealm.
يمكن لـ Apache Shiro مصادقة المستخدمين مقابل دليل LDAP. وللقيام بذلك، يحوّل
اسم المستخدم المُرسَل إلى Distinguished Name — عنوان الدليل الخاص بمدخل ذلك المستخدم —
عن طريق إدراج اسم المستخدم في قالب مُهيَّأ، على سبيل المثال
uid={0},ou=users,dc=mycompany,dc=com.
في الإصدارات المتأثرة يتم لصق اسم المستخدم في ذلك القالب كنص خام. وهناك مجموعة من أحرف الترقيم — وأهمها الفاصلة — ليست نصاً عادياً بالنسبة لخادم الدليل؛ بل هي الصيغة التي تفصل جزءاً من العنوان عن الجزء التالي. لذلك يتوقف اسم المستخدم الذي يحتوي على تلك الأحرف عن التصرف كقيمة داخل العنوان ويبدأ في التصرف كجزء من العنوان نفسه.
النتيجة العملية هي أن الشخص الذي يسجّل الدخول يمكنه التأثير على أي مدخل دليل يحاول Shiro المصادقة مقابله، بدلاً من مجرد تقديم اسمه الخاص. فبدلاً من البحث عنه في الحاوية التي قصدها النشر، يمكن إعادة توجيه البحث إلى مكان آخر في الدليل. واعتماداً على كيفية تنظيم الدليل وما يسمح به، قد يؤدي ذلك إلى المصادقة بهوية خاطئة، أو إلى نجاح المصادقة عندما لا ينبغي لها ذلك.
ملف المخاطر. ما يرفع المخاطر: لا يُشترط أي وصول مسبق أو بيانات اعتماد — يصل
الإدخال عند حدود تسجيل الدخول، التي يمكن لأي شخص يستطيع الوصول إلى التطبيق
الوصول إليها؛ والفئة المتأثرة هي الطريقة القياسية والموثّقة لربط Shiro بـ LDAP،
لذا فهذا ليس إعداداً غريباً. وما يخفضها: يجب أن يستخدم النشر فعلياً
DefaultLdapRealm (أو JndiLdapRealm) مع قالب DN مُهيَّأ؛ أما عمليات النشر
التي تصادق بوسائل أخرى، أو التي تمرر DN كاملاً أو بيانات اعتماد غير نصية مثل
شهادة، فهي غير متأثرة. كما أن ما إذا كان البحث المُعاد توجيهه يُنتج مصادقة قابلة
للاستخدام يعتمد أيضاً على تنظيم الدليل الهدف وقواعد الوصول الخاصة به، والتي تختلف
من موقع لآخر.
تجدر الإشارة إلى نتيجة ثانية غير أمنية لأغراض تخطيط الإصدارات: المستخدمون الذين
تحتوي أسماء مستخدميهم الشرعية على شرطة مائلة عكسية أو تبدأ بـ # لا يمكنهم
حالياً تسجيل الدخول إطلاقاً، لأن العنوان المبني لهم ليس اسماً سليماً ويُرفض
تماماً. أما علامات الترقيم الأخرى فلا تفشل تماماً — بل تغيّر بصمت المدخل الذي
يشير إليه العنوان، وهي المشكلة الأمنية المذكورة أعلاه. ويحل الإصلاح نفسه كلا
المشكلتين.
core/src/main/java/org/apache/shiro/realm/ldap/DefaultLdapRealm.java، getUserDn(String)،
الأسطر 227–250 على الأساس غير المُصلَّح:```java
protected String getUserDn(String principal) throws IllegalArgumentException, IllegalStateException {
if (!StringUtils.hasText(principal)) {
throw new IllegalArgumentException("User principal cannot be null or empty for User DN construction.");
}
String prefix = getUserDnPrefix();
String suffix = getUserDnSuffix();
if (prefix == null && suffix == null) {
log.debug("userDnTemplate property has not been configured, indicating the submitted "
+ "AuthenticationToken's principal is the same as the User DN. Returning the method argument "
+ "as is.");
return principal;
}
int prefixLength = prefix != null ? prefix.length() : 0;
int suffixLength = suffix != null ? suffix.length() : 0;
StringBuilder sb = new StringBuilder(prefixLength + principal.length() + suffixLength);
if (prefixLength > 0) {
sb.append(prefix);
}
sb.append(principal); // <-- inserted verbatim
if (suffixLength > 0) {
sb.append(suffix);
}
return sb.toString();
}
يُقسَّم القالب مرة واحدة، عند وقت التهيئة، حول الرمز `{0}`
(`setUserDnTemplate`، الأسطر 181–200)، إلى `prefix` و`suffix`. عند وقت المصادقة
يُدمَج الـ principal بينهما. **لا يُطبَّق أي ترميز في أي نقطة.** لا يوجد
أي مساعد تهريب في أي مكان ضمن `org/apache/shiro/realm/ldap/`.
### 3.2 الثابت الذي افتُرض لكن لم يُفرَض قط
تتعامل الشيفرة المحيطة مع ناتج `getUserDn` باعتباره Distinguished Name جيد التكوين —
إذ يُمرَّر مباشرة إلى `LdapContextFactory.getLdapContext(...)` ومن هناك إلى JNDI كـ
bind DN. وهذا لا يكون سليماً إلا إذا كان الـ principal المُستبدَل *قيمة سمة واحدة*.
لا يمكن لدمج النصوص فرض ذلك. تحجز RFC 2253 (و RFC 4514) الرموز
`,` `+` `"` `\` `<` `>` `;` `=`، و`#` البادئة، والمسافات البيضاء البادئة/اللاحقة كبنية
نحوية داخل الـ DN. وعندما يظهر أي منها في الـ principal فإنها تُقرأ من قِبَل المحلِّل
كبنية، لا كمحتوى. الثابت — *"يشغل الـ principal قيمة RDN واحدة بالضبط"* —
افتُرض من قِبَل كل مستهلك لاحق ولم يفرضه أيٌّ منهم.
### 3.3 تدفق التنفيذ، من نقطة الدخول إلى العيب```
submitted credentials (username, password)
│
▼
DefaultLdapRealm.doGetAuthenticationInfo(AuthenticationToken) [line 292]
│
▼
DefaultLdapRealm.getLdapPrincipal(AuthenticationToken) [line 338]
│ principal instanceof String ?
│ ├── no ──► return principal unchanged ── NOT AFFECTED (e.g. X.509)
│ └── yes ──┐
▼ │
DefaultLdapRealm.getUserDn(String) [line 227]
│ prefix == null && suffix == null ?
│ ├── yes ──► return principal unchanged ── NOT AFFECTED ("principal IS the DN")
│ └── no ──┐
▼ │
prefix + principal + suffix ◄── DEFECT: unencoded concatenation [line 246]
│
▼
LdapContextFactory.getLdapContext(userDn, credentials)
│
▼
JNDI bind against the directory using the constructed DN
القالب uid={0},ou=users,dc=mycompany,dc=com، والهوية الأساسية jsmith,ou=admins:
| الـ DN المُنشأ | الـ RDNs المُحلَّلة | |
|---|---|---|
| المقصود | uid=jsmith\,ou\=admins,ou=users,dc=mycompany,dc=com | 4 |
| الأساس (غير المُصلَّح) | uid=jsmith,ou=admins,ou=users,dc=mycompany,dc=com | 5 |
يكتسب الاسم مكوّنًا إضافيًا. لم يعد ou=users هو الحاوية المُستهدَفة —
بل تم إقحام ou=admins في المنتصف. تم التحقق من ذلك عبر التحليل باستخدام javax.naming.ldap.LdapName، وهو
مستقل عن أي تطبيق للتهريب.
السلوك المقيس للأساس عبر المجموعة المحجوزة (JDK 8، تحليل LdapName):
حرفان يغيّران بنية الاسم، وليس محتواه فقط: الفاصلة،
والفاصلة المنقوطة — التي يقبلها RFC 1779 كفاصل RDN بديل. أما + فيُدخل قيمة سمة ثانية
في نفس الـ RDN. فقط \ و# في البداية يجعلان الاسم غير قابل للتحليل.
userDnTemplate غير مُعيَّن. يُعيد getUserDn الهوية الأساسية دون تغيير. هذا هو
الوضع الموثَّق "الهوية الأساسية المُقدَّمة هي الـ DN"؛ يوفّر المستدعي DN كاملًا
ويتحمّل مسؤولية صحته. غير متأثر.String. لا يوجّه getLdapPrincipal سوى الهويات الأساسية من نوع String إلى
getUserDn؛ أي شيء آخر (شهادات X.509، رموز مخصصة) يمر دون تغيير. غير متأثر.setUserDnTemplate (الأسطر 181–200) يرفض بشكل صحيح قالبًا فارغًا أو خاليًا أو
لا يحتوي على {0}. إنه يتحقق من القالب المُقدَّم من المشغّل، الذي لم يكن أبدًا
المدخل غير الموثوق — لذا فهو كود صحيح لا يعالج هذا العيب فحسب.JndiLdapRealm يمتد DefaultLdapRealm ولا يتجاوز getUserDn، لذا
يرث العيب. تم التأكيد بالاختبار (§6.1). ضمن النطاق، ويُصلَّح بنفس التغيير.يحمل AbstractLdapRealm.searchFilter (السطر 89) استبدالًا ثانيًا لـ {0}، الافتراضي
(&(objectClass=*)(userPrincipalName={0}))، يُستخدم في مسار التفويض. وهو غير متأثر.
كشف مسح شامل لكل استدعاء search(، وكل مستدعي getLdapContext(، وكل
سلسلة نصية على شكل LDAP عبر المصادر الرئيسية لـ core وsupport وweb عن استخدام واحد فقط لـ
searchFilter — وهو ActiveDirectoryRealm.getRoleNamesForUser، السطر 172:```java
Object[] searchArguments = new Object[]{userPrincipalName};
NamingEnumeration answer = ldapContext.search(searchBase, searchFilter, searchArguments, searchCtls);
هذا هو التحميل الزائد **المُعامَل** `DirContext.search(String, String, Object[], SearchControls)`.
يُمرَّر اسم المستخدم كـ *وسيطة* عامل تصفية، ولا يُدمج أبدًا في سلسلة
عامل التصفية. وفقًا لوثائق JDK 8 `javax.naming.directory.DirContext`، حرفيًا:
> "عند استبدال وسيطة عامل تصفية ذات قيمة نصية بمتغير، يُفسَّر عامل التصفية
> كما لو أن السلسلة أُعطيت مكان المتغير، مع أي أحرف
> ذات دلالة خاصة داخل عوامل التصفية (مثل `'*'`) بعد تهريبها وفقًا
> لقواعد RFC 2254."
يقوم مزوّد JNDI بعملية التهريب. والسجل التاريخي لذلك موجود في إعلان
الحقل نفسه.
**المصدر: `core/src/main/java/org/apache/shiro/realm/ldap/AbstractLdapRealm.java`، الأسطر 88–89**```java
//SHIRO-115 - prevent potential code injection:
protected String searchFilter = "(&(objectClass=*)(userPrincipalName={0}))";
المصدر: core/src/main/java/org/apache/shiro/realm/activedirectory/ActiveDirectoryRealm.java، الأسطر 158–172```java
protected Set getRoleNamesForUser(String username, LdapContext ldapContext) throws NamingException {
Set roleNames;
roleNames = new LinkedHashSet();
SearchControls searchCtls = new SearchControls();
searchCtls.setSearchScope(SearchControls.SUBTREE_SCOPE);
String userPrincipalName = username;
if (principalSuffix != null && !userPrincipalName.toLowerCase(Locale.ROOT).endsWith(principalSuffix.toLowerCase(Locale.ROOT))) {
userPrincipalName += principalSuffix;
}
Object[] searchArguments = new Object[]{userPrincipalName};
NamingEnumeration answer = ldapContext.search(searchBase, searchFilter, searchArguments, searchCtls);
يصل اسم المستخدم إلى الدليل باسم `searchArguments[0]`، وليس كنص مُدرج في
`searchFilter`. يتم حل الرمز `{0}` في الفلتر بواسطة مزوّد JNDI، وليس بواسطة
Shiro.
يمرّر `ActiveDirectoryRealm` في السطر 108 اسم المستخدم الخام إلى
`getLdapContext(username, password)`. تصبح تلك القيمة مدخلاً واحداً لبيئة JNDI
`SECURITY_PRINCIPAL`؛ فهي لا تُستبدل في قالب ولا يُبنى منها DN، وبالتالي فهي خارج
آلية هذا العيب.
**تم التحقق منها تجريبياً مقابل خادم دليل حيّ.** لم يُؤخذ تهريب JNDI
على الثقة: بل لوحظ على الشبكة. تم تجهيز خادم LDAP داخل العملية
(`InMemoryDirectoryServer` من UnboundID) بمعترض
`InMemoryOperationInterceptor` يسجّل فلتر كل طلب بحث يستقبله،
وتم إصدار `searchFilter` الافتراضي الخاص بـ Shiro عبر نفس استدعاء JNDI المُعامَل المستخدم
بواسطة `ActiveDirectoryRealm`، مع وسيط مُصمَّم:```
filter template : (&(objectClass=*)(userPrincipalName={0}))
argument passed : *)(uid=jsmith
server received : (&(objectClass=*)(userPrincipalName=\2a\29\28uid=jsmith))
وصلت * و ) و ( إلى الخادم بصيغة \2a و \29 و \28 — مُهرَّبة وفق
RFC 2254. يحتفظ الفلتر بجملتين بالضبط؛ ولم يتمكن الوسيط من إضافة جملة ثالثة.
التحكم، نفس القالب مع إدراج الوسيط كنص خام بدلاً من تمريره كوسيط فلتر:``` CONTROL, raw splice: (&(objectClass=)(userPrincipalName=)(uid=jsmith)) server received : (&(objectClass=)(userPrincipalName=)(uid=jsmith))
يصل عنصر التحكم إلى الخادم وقد تغيّرت بنيته — أُضيف شرط ثالث واختُصر اختبار `userPrincipalName` إلى محرف بديل. أما الصيغة ذات المعاملات فلا تصل. وهذا يؤكد، بالملاحظة لا بالعقد، أن مسار `searchFilter` غير متأثر.
---
## 4. خطوات إعادة الإنتاج التفصيلية
حتمية، انطلاقًا من نسخة نظيفة. دليل العمل هو جذر المستودع في كل ما يلي.
### 4.1 المتطلبات المسبقة
| المتطلب | القيمة المستخدمة |
|---|---|
| JDK | **8** — يضبط الفرع `jdk.version` على 1.8. أُنتجت النتائج أدناه باستخدام Zulu 1.8.0_432 (arm64). وجّه `JAVA_HOME` إلى تثبيت JDK 8 قبل تشغيل أي أمر في هذا القسم: |
| البناء | Apache Maven، يتطلب الوصول إلى الشبكة (POM الأب `org.apache:apache:38`) |
| الأساس | `origin/1.13.x` |
| الإعداد | `userDnTemplate = uid={0},ou=users,dc=mycompany,dc=com` |
| خادم الدليل | **غير مطلوب للفقرات §4.3-§4.4** — العيب في بناء DN، قبل أي استدعاء شبكي، لذا تحاكي تلك الخطوات `LdapContextFactory`. إضافة إلى ذلك، تعيد §4.5 إنتاج المسار كاملًا مقابل خادم LDAP **حقيقي** عبر HTTP. |
ضبط `JAVA_HOME` على تثبيت JDK 8:```bash
# macOS
export JAVA_HOME=$(/usr/libexec/java_home -v 1.8)
# Linux (path varies by distribution and vendor)
export JAVA_HOME=/usr/lib/jvm/java-8-openjdk-amd64
# Windows (cmd)
set JAVA_HOME=C:\Program Files\Zulu\zulu-8
تأكد باستخدام mvn -v، الذي يوضح إصدار JDK الذي سيستخدمه Maven. تفترض الأوامر أدناه
أن هذا قد تم ضبطه بالفعل.
git clone https://github.com/apache/shiro.git cd shiro git checkout -b repro origin/1.13.x
### 4.3 مراقبة العيب مباشرةً
هذا هو المُشغِّل الأدنى، بمعزل عن بناء Shiro. اكتب `Repro.java` في دليل مؤقت:```java
import javax.naming.ldap.LdapName;
public class Repro {
// reproduces DefaultLdapRealm.getUserDn line-for-line
static String getUserDn(String prefix, String principal, String suffix) {
StringBuilder sb = new StringBuilder(prefix.length() + principal.length() + suffix.length());
sb.append(prefix);
sb.append(principal);
sb.append(suffix);
return sb.toString();
}
public static void main(String[] args) throws Exception {
String prefix = "uid=";
String suffix = ",ou=users,dc=mycompany,dc=com";
String benign = getUserDn(prefix, "jsmith", suffix);
System.out.println("benign : " + benign + " -> RDNs=" + new LdapName(benign).size());
String crafted = getUserDn(prefix, "jsmith,ou=admins", suffix);
System.out.println("crafted: " + crafted + " -> RDNs=" + new LdapName(crafted).size());
}
}
شغّله:```bash javac Repro.java && java Repro
المخرجات المرصودة:```
benign : uid=jsmith,ou=users,dc=mycompany,dc=com -> RDNs=4
crafted: uid=jsmith,ou=admins,ou=users,dc=mycompany,dc=com -> RDNs=5
يتغير عدد المكونات من 4 إلى 5. أصبحت القيمة المُرسلة بنية الاسم.
من جذر المستودع على الأساس غير المُصلَّح، طبّق الاختبارات في §6.3 وشغّل:```bash mvn -B clean verify
يتوقف البناء عند `Apache Shiro :: Core` مع فشل اختبارات الإثبات — راجع §6.1 للحصول على
المخرجات الكاملة المسجلة:```
DefaultLdapRealmTest Tests run: 16, Failures: 4, Errors: 0, Skipped: 0
JndiLdapRealmTest Tests run: 16, Failures: 4, Errors: 0, Skipped: 0
تمت إعادة الإنتاج عبر كامل المكدس — طلب HTTP حقيقي، وحاوية servlet حقيقية،
وFormAuthenticationFilter الخاص بـ Shiro، وخادم LDAP حقيقي. لا شيء وهمي في أي
طبقة.
تجهيزة الدليل — حاوية مميزة متداخلة، وهي بنية شائعة في العالم الحقيقي:``` dc=mycompany,dc=com └── ou=users ├── uid=jsmith userPassword: userpass (ordinary account) └── ou=admins └── uid=jsmith userPassword: adminpass (privileged account)
#### 4.5.1 البناء المتأثر
**الطلب**```http
POST /login HTTP/1.1
Host: 127.0.0.1
Content-Type: application/x-www-form-urlencoded
username=jsmith%2Cou%3Dadmins&password=adminpass
الاستجابة```http HTTP/1.1 302 Found Location: http://127.0.0.1/;jsessionid=node0qu3v2n612vy71alsuyir2bgzz1.node0
**Bind DN الذي استقبله الدليل**```
uid=jsmith,ou=admins,ou=users,dc=mycompany,dc=com
إعادة التوجيه 302 هي إعادة توجيه النجاح الخاصة بـ FormAuthenticationFilter: الطلب تمت مصادقته.
يحمل DN خمسة مكونات حيث يحدد القالب أربعة، والمدخل الذي تم الوصول إليه
هو المدخل ذو الصلاحيات تحت ou=admins.
التحكم — نفس اسم المستخدم مع كلمة مرور الحساب العادي```http POST /login HTTP/1.1 Content-Type: application/x-www-form-urlencoded
username=jsmith%2Cou%3Dadmins&password=userpass
## التثبيت
### المتطلبات الأساسية
- Python 3.8 أو أحدث
- pip (مدير حزم Python)
- Git (اختياري، للتثبيت من المصدر)
### التثبيت عبر pip
```bash
pip install pycryptodome
git clone https://github.com/example/crypto-tool.git
cd crypto-tool
pip install -r requirements.txt
python -c "import Crypto; print(Crypto.__version__)"
from Crypto.Cipher import AES
from Crypto.Random import get_random_bytes
key = get_random_bytes(32)
cipher = AES.new(key, AES.MODE_GCM)
ciphertext, tag = cipher.encrypt_and_digest(b"secret message")
| الخيار | الوصف |
|---|---|
-k, --key | مفتاح التشفير بصيغة hex |
-i, --input | ملف الإدخال |
-o, --output |
تشفير ملف باستخدام AES-256:
python crypto-tool.py -k 0123456789abcdef0123456789abcdef -i plaintext.txt -o encrypted.bin
فك تشفير ملف:
python crypto-tool.py -d -k 0123456789abcdef0123456789abcdef -i encrypted.bin -o decrypted.txt
Cipher: الفئة الأساسية لجميع عمليات التشفيرHash: فئة التجزئة لحساب قيم التجزئةKey: فئة إدارة المفاتيحfrom crypto_tool import Cipher, Hash
cipher = Cipher(algorithm="AES-256-GCM")
encrypted = cipher.encrypt(data, key)
hash_value = Hash.sha256(data)
| المتغير | الوصف | القيمة الافتراضية |
|---|
crypto:
algorithm: AES-256-GCM
key_size: 32
log_level: INFO
``````http
HTTP/1.1 200 OK
مرفوض. هذا ما يثبت انتحال الهوية بدلاً من المصادفة: اسم المستخدم المُصمَّم يُصادَق عليه بكلمة مرور الحساب ذي الصلاحيات ويفشل مع كلمة مرور المستخدم العادي، لذا فإن بيانات الاعتماد التي يتم التحقق منها تنتمي إلى إدخال دليل مختلف عن العناوين التي يستهدفها القالب.
Bind DN الذي يستقبله الدليل لاسم المستخدم المُصمَّم:``` affected : uid=jsmith,ou=admins,ou=users,dc=mycompany,dc=com (5 components) remediated : uid=jsmith,ou=admins,ou=users,dc=mycompany,dc=com (4 components)
تسجيلات الدخول العادية دون تغيير — الصف 1 يُصادق بشكل مطابق على كلا البنيتين، مع استلام الدليل `uid=jsmith,ou=users,dc=mycompany,dc=com`.
استخدم كلا التشغيلين نفس أداة الاختبار ونفس بيانات الاختبار؛ فقط `shiro-core` على مسار الفئات (classpath) اختلف. تم التأكد من الفئة التي حُمِّلت فعلياً باستخدام `-verbose:class` لكل تشغيل.
---
## 5. تفاصيل المعالجة وكود المعالجة
### 5.1 الإصلاح الأساسي
قم بترميز الـ principal كقيمة سمة DN واحدة قبل الاستبدال، باستخدام مُرمِّز RFC 2253 الخاص بـ JDK، وهو `javax.naming.ldap.Rdn.escapeValue`. هذه هي نفس الآلية التي يستخدمها JDK لبناء الأسماء، لذا فإن مخرجاته متوافقة بحكم البناء مع المحلل الذي يستهلكها.
**الملف:** `core/src/main/java/org/apache/shiro/realm/ldap/DefaultLdapRealm.java````diff
@@ -35,6 +35,7 @@ import org.slf4j.LoggerFactory;
import javax.naming.AuthenticationNotSupportedException;
import javax.naming.NamingException;
import javax.naming.ldap.LdapContext;
+import javax.naming.ldap.Rdn;
/**
* An LDAP {@link org.apache.shiro.realm.Realm Realm} implementation utilizing Sun's/Oracle's
@@ -239,11 +240,14 @@ public class DefaultLdapRealm extends AuthorizingRealm {
int prefixLength = prefix != null ? prefix.length() : 0;
int suffixLength = suffix != null ? suffix.length() : 0;
- StringBuilder sb = new StringBuilder(prefixLength + principal.length() + suffixLength);
+ //the principal is a single attribute value within the resulting name, so it is encoded
+ //to keep any characters that are significant in a Distinguished Name within that value:
+ String value = Rdn.escapeValue(principal);
+ StringBuilder sb = new StringBuilder(prefixLength + value.length() + suffixLength);
if (prefixLength > 0) {
sb.append(prefix);
}
- sb.append(principal);
+ sb.append(value);
if (suffixLength > 0) {
sb.append(suffix);
}
ما الذي يفرضه الـ hunk. يقوم Rdn.escapeValue بتهريب الشرطة العكسية لمجموعة RFC 2253 المحجوزة
(\ , = + < > # ; ") بالإضافة إلى المسافات البيضاء البادئة واللاحقة. بعد ذلك، لا يمكن للنص المُستبدل
أن يشغل إلا قيمة RDN واحدة: يقرأ المحلل الأحرف المُهرَّبة كمحتوى، لذا
يكون عدد المكونات في النتيجة ثابتًا حسب القالب ولا يمكن أن يتأثر
بالـ principal. يتم تحديث تلميح سعة StringBuilder إلى الطول المُرمَّز — وهو تفصيل
حجمي، وليس سلوكيًا.
الموضع. يقع الترميز بعد الإرجاع المبكر لحالة القالب غير المُهيأ. وهذا مقصود: في ذلك النمط يوفّر المستدعي DN كاملًا وترميزه سيفسده. يحتفظ كلا الفرعين بعقودهما الحالية.
هذا الإصلاح يغيّر سلسلة DN الناتجة لأي principal يحتوي على حرف محجوز. ثلاث نتائج جديرة بالذكر بوضوح:
# أنتجت DN لم يكن اسمًا جيد التكوين، لذا لم يكن بالإمكان محاولة أي bind على
الإطلاق. الآن تُرمَّز إلى DN صالح وستحاول bind. قد ترى المواقع بدء
مصادقة حسابات لم تكن قادرة على ذلك سابقًا — وهو إصلاح، لكنه تغيير مرئي. أسماء المستخدمين
التي تحتوي على , أو ; أو + أو = لم تكن مرفوضة سابقًا؛ فقد كانت تُحل إلى
الإدخال الخاطئ، والآن تُحل إلى الإدخال المقصود.uid=jsmith\,ou\=admins,...). لا يُطلب أي تغيير في
التكوين.getUserDnTemplate() مُنفَّذ كـ getUserDn("{0}"). الحرفان { و } غير محجوزين في
RFC 2253، لذا فإن قيمة إرجاع الـ accessor دون تغيير. مؤكَّد بواسطة
testUserDnTemplate الموجود مسبقًا، والذي ينجح دون تعديل.
الفرع 1.13-CVE-2026-49268، JDK Zulu 1.8.0_432. الأمر كما في §4.4.```
DefaultLdapRealmTest Tests run: 16, Failures: 4, Errors: 0, Skipped: 0
JndiLdapRealmTest Tests run: 16, Failures: 4, Errors: 0, Skipped: 0
مخرجات الفشل المُلتقطة — هذا هو الدليل، وهو غير قابل للاسترداد بمجرد تطبيق الإصلاح:```
testGetUserDnPreservesTemplateStructure:238
User DN gained or lost components relative to the template:
uid=jsmith,ou=admins,ou=users,dc=mycompany,dc=com
expected:<4> but was:<5>
testGetUserDnPreservesTemplateStructureForReservedCharacters:262
Component count changed for principal [jsmith,ou=admins]:
uid=jsmith,ou=admins,ou=users,dc=mycompany,dc=com
expected:<4> but was:<5>
testGetUserDnEncodesSubstitutedValue:280
expected:<uid=jsmith[\,ou\]=admins,ou=users,dc=...>
but was:<uid=jsmith[,ou]=admins,ou=users,dc=...>
testUserDnTemplateSubstitutionPreservesStructure:300
Unexpected method call
LdapContextFactory.getLdapContext("uid=jsmith,ou=admins,ou=users,dc=mycompany,dc=com", ...)
expected:
LdapContextFactory.getLdapContext("uid=jsmith\,ou\=admins,ou=users,dc=mycompany,dc=com", ...)
expected: 1, actual: 0
testGetUserDnLeavesOrdinaryPrincipalUnchanged نجح على خط الأساس، كما هو مقصود — فهو
حارس ضد التصحيح المفرط، وليس اختبارًا إثباتيًا.
نفس الأمر، نفس JDK:``` DefaultLdapRealmTest Tests run: 16, Failures: 0, Errors: 0, Skipped: 0 JndiLdapRealmTest Tests run: 16, Failures: 0, Errors: 0, Skipped: 0
جميع اختبارات الإثبات الأربعة تمر الآن على كلا الصنفين. إعادة تشغيل §4.3 مع الإصلاح المطبق
تُنتج `RDNs=4` للهوية المُصمَّمة.
### 6.3 الاختبارات المُضافة
**الملف:** `core/src/test/java/org/apache/shiro/realm/ldap/DefaultLdapRealmTest.java`
(+124 سطرًا، 0 حذف). لأن `JndiLdapRealmTest extends DefaultLdapRealmTest`، فإن كل
اختبار أدناه يُنفَّذ ضد **كلا** الصنفين — 10 عمليات تنفيذ من 5 دوال.
| الاختبار | ما يتحقق منه | خط الأساس |
|---|---|---|
| `testGetUserDnLeavesOrdinaryPrincipalUnchanged` | هوية عادية تُنتج الـ DN المتوقع بالضبط، 4 RDNs، القيمة سليمة. يحمي من الإفراط في الترميز. | **ناجح** (حارس) |
| `testGetUserDnPreservesTemplateStructure` | بالنسبة لـ `jsmith,ou=admins` لا يزال الـ DN يحتوي على 4 مكونات والهوية تبقى كقيمة سمة واحدة. | فاشل |
| `testGetUserDnPreservesTemplateStructureForReservedCharacters` | الأمر ذاته، عبر جميع الهويات العشر ذات الأحرف المحجوزة؛ كما يفشل الاختبار إذا لم يكن الـ DN اسمًا جيد التكوين. | فاشل |
| `testGetUserDnEncodesSubstitutedValue` | القيمة المُستبدَلة مُرمَّزة بحيث تعود ذهابًا وإيابًا إلى الهوية المُقدَّمة. | فاشل |
| `testUserDnTemplateSubstitutionPreservesStructure` | من البداية إلى النهاية عبر `getAuthenticationInfo`: الـ DN المُسلَّم إلى `LdapContextFactory` يحتفظ ببنية القالب. | فاشل |
**ملاحظة تصميمية.** تُحلِّل التأكيدات البنيوية النتيجة باستخدام `javax.naming.ldap.LdapName`
وتقارن عدد المكونات وقيمة الورقة العائدة ذهابًا وإيابًا، بدلًا من تأكيد سلسلة مُرمَّزة متوقعة.
وبالتالي تتحقق الاختبارات من الخاصية المطلوبة الفعلية ولا تفترض مسبقًا أن `Rdn.escapeValue` هو
التنفيذ — أي مُرمِّز صحيح بديل سيظل ينجح.
الأمر:```bash
mvn -B clean verify
يمر بناء المفاعل الكامل بدون أعلام وبدون تخطيات:``` mvn -B clean verify
**BUILD SUCCESS — 907 اختبارًا، 0 إخفاقات، 0 أخطاء، 3 متخطاة، عبر المفاعل الكامل.**
كل بوابة نشطة في هذه التشغيلة: اختبارات الوحدة (surefire)، اختبارات التكامل (failsafe)،
تدقيق ترخيص Apache RAT، maven-enforcer، و japicmp.
| البوابة | النتيجة |
|---|---|
| اختبارات الوحدة، المفاعل الكامل | **907 تشغيل، 0 إخفاقات، 0 أخطاء، 3 متخطاة** |
| وحدة `core` وحدها | **321 تشغيل، 0 إخفاقات، 0 أخطاء، 0 متخطاة** |
| اختبارات LDAP (`DefaultLdapRealmTest` + `JndiLdapRealmTest`) | **32 تشغيل، 0 إخفاقات** |
| اختبارات التكامل (failsafe) | شُغّلت عبر المفاعل، لا إخفاقات |
| تدقيق ترخيص Apache RAT | غير معتمد: 0، غير معروف: 0 |
| maven-enforcer | لا انتهاكات |
| japicmp | لم يُبلَّغ عن أي عدم توافق |
التخطيات الثلاث هي `@Ignore`s موجودة مسبقًا في وحدات غير مرتبطة بهذا التغيير؛ `core` — الوحدة الوحيدة التي مُسّت — لا تتخطى شيئًا.
## المراجع
- سجل CVE (MITRE API): `https://cveawg.mitre.org/api/cve/CVE-2026-49268`
- الإعلان الأصلي: `https://lists.apache.org/thread/svszql3od8td7hn6conyj2oq70v53b5s`
- تقارير أمان Apache Shiro: `https://shiro.apache.org/security-reports.html`
- RFC 2253 / RFC 4514 — التمثيل النصي لـ LDAP للأسماء المميزة (Distinguished Names)
- RFC 4515 — التمثيل النصي لمرشح بحث LDAP
## التواصل
مؤلف أبحاث ومعالجة الثغرات الأمنية: Jinwoo Hwang ([https://JinwooHwang.com](https://jinwoohwang.com/))
| الحقل | القيمة |
|---|
| CVE | CVE-2026-49268 — Apache Shiro: حقن LDAP DN في DefaultLdapRealm |
| CWE ID | CWE-90 — التحييد غير السليم للعناصر الخاصة المستخدمة في استعلام LDAP ('حقن LDAP') |
| CVSS v4.0 | 8.8 HIGH — CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:H/VA:N/SC:N/SI:N/SA:N/S:P/AU:Y/R:A/RE:L/U:Red |
| CVSS v3.1 | 9.1 CRITICAL — CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N |
| الإصدارات المتأثرة | org.apache.shiro:shiro-core 0 حتى 2.2.0 ضمناً؛ 3.0.0-alpha-0 حتى 3.0.0-alpha-1 ضمناً |
| تم الإصلاح في الإصدارات الأصلية | 2.2.1 و3.0.0-alpha-2 |
| المرجع | https://lists.apache.org/thread/svszql3od8td7hn6conyj2oq70v53b5s |
| الهوية الأساسية | نتيجة الأساس | التأثير |
|---|
jsmith,ou=admins | يُحلَّل، 5 RDNs | تغيّر البنية — تم إقحام حاوية |
jsmith;ou=admins | يُحلَّل، 5 RDNs | تغيّر البنية — ; فاصل RDN وفق RFC 1779 |
jsmith+uid=admin | يُحلَّل، 4 RDNs | يصبح RDN متعدد القيم؛ يكتسب uid قيمة ثانية |
jsmith=admin | يُحلَّل، 4 RDNs | تلف القيمة |
quo"te، angle<br>ackets، leadingSpace، trailingSpace | تُحلَّل، 4 RDNs | تلف القيمة |
back\slash | IllegalArgumentException | مشوّه — لا يمكن محاولة الربط |
#leadingNumberSign | IllegalArgumentException | مشوّه — لا يمكن محاولة الربط |
| الطبقة | المكوّن |
|---|
| عميل HTTP | HttpURLConnection، إرسال نموذج خام عبر POST |
| حاوية Servlet | Embedded Jetty 9.4.58.v20250814 |
| مرشّح الأمان | ShiroFilter + EnvironmentLoaderListener، authc (FormAuthenticationFilter) |
| Realm | DefaultLdapRealm، userDnTemplate = uid={0},ou=users,dc=mycompany,dc=com |
| الدليل | UnboundID InMemoryDirectoryServer، مُجهّز لتسجيل كل bind DN |
| ملف الإخراج |
-v, --verbose | تفعيل الإخراج المفصل |
CRYPTO_KEY | مفتاح التشفير الافتراضي | لا يوجد |
CRYPTO_LOG_LEVEL | مستوى التسجيل | INFO |
CRYPTO_OUTPUT_DIR | دليل الإخراج | . |
| الطلب | المتأثر | المُعالَج |
|---|
username=jsmith&password=userpass | 302 مصادَق عليه | 302 مصادَق عليه |
username=jsmith%2Cou%3Dadmins&password=adminpass | 302 مصادَق عليه | 200 مرفوض |
username=jsmith%2Cou%3Dadmins&password=userpass | 200 مرفوض | 200 مرفوض |