
Use-After-Free في Netfilter nf_tables عند معالجة الطلبات المجمّعة CVE-2023-32233

الكود المتأثر مصدره نواة لينكس الرسمية من https://kernel.org/ وهو جزء من مكوّن Netfilter nf_tables (net/netfilter/nf_tables_api.c).
تتيح Netfilter nf_tables تحديث إعداداتها كعملية ذرّية. عند استخدام هذه الميزة، ترسل العملاء في وضع المستخدم طلبات دفعات تحتوي على قائمة من العمليات الأساسية. ثم تعالج Netfilter nf_tables جميع العمليات داخل الدفعة كمعاملة واحدة. عند معالجة الدفعة، تتحقق Netfilter nf_tables من تحديثات حالة الإعداد لضمان أنّ كل عملية أساسية لاحقة صالحة، ويشمل ذلك أيضًا تحديثات الحالة الناتجة عن جميع العمليات السابقة داخل الدفعة. ومع ذلك، فإن الفحص المطبَّق حاليًا غير كافٍ.
في السيناريو الخاص بنا، نبدأ بإعداد Netfilter nf_tables يحتوي على nft_rule مع تعبير lookup على nft_set مجهول، وحيث يحتوي هذا nft_set المجهول على بعض العناصر. بعد ذلك، نرسل طلب دفعة يحتوي على العمليتين الأساسيتين التاليتين:
NFT_MSG_DELRULE لحذف nft_rule.lookup وnft_set المجهول.NFT_MSG_DELSETELEM لحذف أيٍّ من عناصر nft_set المجهول المحذوف.الإصدار الحالي من Netfilter nf_tables يقبل طلب الدفعة أعلاه. ثم يستدعي nf_tables_commit_release() الذي يضيف الموارد المحرَّرة إلى nf_tables_destroy_list. ثم تتم معالجة nf_tables_destroy_list بواسطة nf_tables_trans_destroy_work() الذي يقوم أولاً بتحرير الموارد المرتبطة بعملية NFT_MSG_DELRULE عن طريق استدعاء:
nft_commit_release()
nf_tables_rule_destroy()
nf_tables_expr_destroy()
expr->ops->destroy() that points to nft_lookup_destroy()
nf_tables_destroy_set()
nft_set_destroy()
kvfree() that deallocates memory used by `nft_set`
قبل معالجة عملية NFT_MSG_DELSETELEM، حيث يتم الوصول إلى مرجع nft_set المحرَّر عبر nft_trans_elem_set() أثناء الاستدعاءات التالية:
nft_commit_release()
nf_tables_set_elem_destroy()
nft_set_elem_ext()
داخل nft_set_elem_ext() أعلاه، يتم الوصول إلى موقع الذاكرة الخاص بـ nft_set المحرَّر لتحديد موقع nft_set_ext:
static inline struct nft_set_ext *nft_set_elem_ext(const struct nft_set *set,
void *elem)
{
return elem + set->ops->elemsize;
}
بالنسبة للعمليات التالية. لذلك، متى ما تم إفساد قيمة set->ops->elemsize، يمكن تفسير موقع ذاكرة غير متوقع كقائمة من nft_expr التي سيتم تدميرها:
static void nf_tables_set_elem_destroy(const struct nft_ctx *ctx,
const struct nft_set *set, void *elem)
{
struct nft_set_ext *ext = nft_set_elem_ext(set, elem);
if (nft_set_ext_exists(ext, NFT_SET_EXT_EXPRESSIONS))
nft_set_elem_expr_destroy(ctx, nft_set_ext_expr(ext));
يتطلب استغلال الثغرة أعلاه الفوز في سباق مع nf_tables_trans_destroy_work() الذي ينفَّذ من مؤشر ترابط عامل في الخلفية من نواة لينكس. يبدو أن هذا يعقّد الاستغلال العملي حتى قبل أن نأخذ في الاعتبار التخفيفات الموجودة، مثل تحصين مُخصِّص الشرائح (slab allocator) في النواة، والعشوائية في تخطيط مساحة العنوان للنواة (KASLR)، وخاصة سلامة تدفق التحكم (Control-Flow Integrity). ومع ذلك، يُثبت إثبات المفهوم (PoC) المرفق أنه لا يزال من الممكن تحقيق استغلال موثوق بشكل معقول عمليًا.
من أجل استغلال الثغرة، نحتاج إلى تعديل محتوى الذاكرة الخاصة بـ nft_set بعد تحريرها في nf_tables_rule_destroy()، ولكن قبل استخدامها في nf_tables_set_elem_destroy(). يتم استدعاء كل من nf_tables_rule_destroy() وnf_tables_set_elem_destroy() داخل استدعاء واحد لـ nf_tables_trans_destroy_work() الذي ينفَّذ من مؤشر ترابط عامل في الخلفية من نواة لينكس. علاوة على ذلك، فإن كتلة الذاكرة المحرَّرة عادةً ما تكون متاحة لإعادة الاستخدام فقط من نفس نواة المعالج (CPU core).
عند السباق مع nf_tables_trans_destroy_work()، نُحسّن فرصنا بإضافة تأخير مُتحكَّم فيه لمؤشر ترابط العامل في الخلفية بين استدعاء nf_tables_rule_destroy() وnf_tables_set_elem_destroy(). للقيام بذلك، نُدرج عملية إضافية لتدمير nft_set آخر يحتوي على عدد كبير من العناصر. بالإضافة إلى ذلك، نُبقي جميع أنوية المعالج الأخرى مشغولة، بحيث يُرجَّح أن تتم جدولة مؤشر ترابط العامل في الخلفية على نواة معالج محددة، وبالتالي يمكننا محاولة تخصيص بنية جديدة من نفس نواة المعالج مباشرة بعد أن يحرّر nft_set في nf_tables_rule_destroy(). هدفنا هو تخصيص nft_set جديد من نوع مختلف لإعادة استخدام موقع الذاكرة الخاص بـ nft_set المحرَّر في nf_tables_rule_destroy().
يتم اختيار نوع nft_set الجديد بحيث يستخدم قيمة مختلفة لـ set->ops->elemsize. لذلك، عندما يستدعي مؤشر ترابط العامل في الخلفية أخيرًا nf_tables_set_elem_destroy() لمعالجة عملية NFT_MSG_DELSETELEM، فإنه يفسّر الوسيط elem بشكل غير صحيح، بحيث يكون المؤشر المُفسَد nft_set_ext *ext على بضعة بايتات بعد الموقع الصحيح. هذا يعني أن بعض حقول البيانات التي يتحكم فيها المستخدم من nft_set_ext الأصلي تُفسَّر الآن كترويسات، مما يؤدي إلى التباس النوع (type confusion).
إحدى طرق إساءة استخدام هذا الالتباس في النوع هي صياغة ترويسات nft_set_ext المُفسدة بقيم إزاحات بحيث يفسّر nf_tables_set_elem_destroy() محتوى أي كتل ذاكرة مجاورة كقائمة من nft_expr لتدميرها عبر الاستدعاءات التالية:
nft_set_elem_expr_destroy()
__nft_set_elem_expr_destroy()
nf_tables_expr_destroy()
expr->ops->destroy()
في هذه المرحلة من الاستغلال، لا نملك بعد تفاصيل تخطيط ذاكرة النواة. لذلك ليس من الممكن صياغة عناوين مؤشرات مطلقة. ومع ذلك، عند صياغة ترويسات nft_set_ext المُفسدة، يمكننا استخدام إزاحات خارج النطاق، بحيث يتم استدعاء expr->ops->destroy() على بعض nft_expr الصالحة في كتل الذاكرة المجاورة.
للقيام بذلك، نقوم برش تعبيرات nft_log مع NFTA_LOG_PREFIX مُتحكَّم فيه. ثم يتم تحرير nft_log->prefix بواسطة nft_log_destroy() بمجرد استدعاء expr->ops->destroy():
static void nft_log_destroy(const struct nft_ctx *ctx,
const struct nft_expr *expr)
{
struct nft_log *priv = nft_expr_priv(expr);
struct nf_loginfo *li = &priv->loginfo;
if (priv->prefix != nft_log_null_prefix)
kfree(priv->prefix);
لاحظ أنه لا يزال بإمكاننا الوصول إلى هذه الذاكرة وحتى تحريرها مرة أخرى عبر المرجع الآخر من تعبير nft_log المُرَشَّش. بالإضافة إلى ذلك، يمكننا أيضًا التحكم في حجم nft_log->prefix، بحيث يمكن تخصيصه من أيٍّ من الشرائح kmalloc-{8, ..., 192}. أخيرًا، تفسّر النواة الذاكرة المشار إليها كسلسلة من الأحرف، لذلك لا داعي للقلق بشأن الإفساد عند تراكب كائنات مختلفة فوقها. هذا يعني في جوهره نهاية اللعبة (game over).
أحد المضايقات هو أن أي أحرف NULL تُنهي nft_log->prefix، لذلك لا يمكننا القراءة بعد بايتات NULL عند تسريب محتوى الذاكرة. تتم معالجة هذا في الخطوة التالية، حيث نخصص nft_object->udata لإعادة استخدام كتلة ذاكرة nft_log->prefix وتدمير تعبير nft_log. هذا يحرّر ذاكرة nft_object->udata، لكن الآن لا يزال بإمكاننا استخدام المؤشر المعلّق nft_object->udata لتسريب محتوى الذاكرة دون قيود على بايتات NULL.
بحثًا عن بنى مناسبة للخطوات التالية، اخترنا nft_expr المُخصص من nft_dynset_new(). تعيش هذه في نفس الشرائح مثل nft_log->prefix وnft_object->udata. وأيضًا، لدينا تحكم معقول في حجم التخصيص، بحيث يمكننا لاحقًا التبديل بسهولة بين شرائح بأحجام مختلفة إذا لزم الأمر.
لاستخدام هذه البنى، ننشئ مُرشّح حزم مع تعبير nft_dynset. وعندما نرسل أي حزم عبر واجهة الاسترجاع (loopback)، يستدعي تعبير nft_dynset الدالة nft_dynset_new() لإنشاء عناصر جديدة لـ nft_set المرتبط. العناصر المُنشأة هي تعبيرات ذات حالة من الأنواع التالية:
nft_counter للحصول على موقع nf_tables.ko في ذاكرة النواة.
تتضمن البنية مؤشرًا إلى nft_counter_ops في وحدة النواة nf_tables.ko. نُسرّب هذا المؤشر بقراءة nft_object->udata.
nft_quota للقراءة والكتابة العشوائية في الذاكرة.
يمكننا تحرير وإعادة تخصيص nft_object->udata بشكل متكرر لتعديل مؤشر nft_quota->consumed. بعد ذلك، ننفّذ عملية NFT_MSG_GETSETELEM التي تستدعي nft_quota_do_dump() لقراءة محتوى الذاكرة المُشار إليها وتمرّر النتيجة كسمة NFTA_QUOTA_CONSUMED في النتيجة. أما بالنسبة للكتابة، فنرسل ببساطة حزمًا عبر واجهة الاسترجاع، حيث تستدعي nft_quota_do_eval() ما يلي:
static inline bool nft_overquota(struct nft_quota *priv,
const struct sk_buff *skb)
{
return atomic64_add_return(skb->len, priv->consumed) >=
لتعديل nft_quota->consumed.
نستخدم قراءة الذاكرة العشوائية أعلاه للحصول على العنوان الأساسي لنواة النظام. ثم نتابع تعديل السلسلة الفرعية "sbin" من اسم المسار "/sbin/modprobe"، بحيث يتم استبدالها بـ "/tmp". ثم تستخدم النواة اسم المسار الناتج "//tmp/modprobe" لبدء عملية بصلاحيات الجذر (root)، حيث نتحكم في محتوى الملف.
لاحظ أننا لم نبذل أي جهد متعمّد لتجاوز سلامة تدفق التحكم (Control-Flow Integrity). ومع ذلك، لكل خطوة من خطوات الاستغلال، اخترنا بوعي البدائيات الأكثر مرونة والأكثر متانة. اتضح أن اختيارنا تجنّب بطريقة ما أيًا من البدائيات التي قد يحتمل أن تحجبها سلامة تدفق التحكم. نحن الآن مهتمون بتأكيد عبر الاختبار أن الاستغلال الناتج يعمل فعلاً ضد الأنظمة التي تطبّق تخفيفات سلامة تدفق التحكم.