
Analyse approfondie de CVE-2022-2590, une variante de Dirty COW dans le shmem du noyau Linux, incluant la cause racine, la condition de concurrence et un exploit de preuve de concept.
version du noyau Linux : Linux/x86 6.0.0-rc1 (commit 37887783b3fef877bf34b8992c9199864da4afcb)
Cette vulnérabilité permet à un attaquant d'écrire un contenu arbitraire dans une page mémoire partagée en lecture seule en satisfaisant la fonction can_follow_write_pte, qui vérifie FOLL_FORCE, FOLL_COW et pte_dirty, à l'aide de UFFDIO_CONTINUE.
/*
* FOLL_FORCE can write to even unwritable pte's, but only
* after we've gone through a COW cycle and they are dirty.
*/
static inline bool can_follow_write_pte(pte_t pte, unsigned int flags)
{
return pte_write(pte) ||
((flags & FOLL_FORCE) && (flags & FOLL_COW) && pte_dirty(pte));
}
La fonction ci-dessus détermine si une pte est accessible en écriture.
La pte est considérée comme accessible en écriture si elle remplit l'une des conditions suivantes :
Pour satisfaire la condition 2, les trois flags suivants doivent être définis :
FOLL_FORCE
static ssize_t mem_rw(struct file *file, char __user *buf,
size_t count, loff_t *ppos, int write)
{
...
flags = FOLL_FORCE | (write ? FOLL_WRITE : 0);
while (count > 0) {
size_t this_len = min_t(size_t, count, PAGE_SIZE);
if (write && copy_from_user(page, buf, this_len)) {
copied = -EFAULT;
break;
}
this_len = access_remote_vm(mm, addr, page, this_len, flags); // __get_user_pages with FOLL_FORCE on
if (!this_len) {
if (!copied)
copied = -EIO;
break;
}
...
}
La fonction ci-dessus effectue des opérations de lecture/écriture sur /proc/<pid>/mem.
En utilisant cette fonction, vous pouvez atteindre la fonction __get_user_pages avec le flag FOLL_FORCE activé.
Bien que FOLL_FORCE soit destiné à être utilisé dans ptrace, sa nécessité a été prouvée dans le commit suivant : https://github.com/torvalds/linux/commit/f511c0b17b081562dca8ac5061dfa86db4c66cc2
FOLL_COW
/*
* mmap_lock must be held on entry. If @locked != NULL and *@flags
* does not include FOLL_NOWAIT, the mmap_lock may be released. If it
* is, *@locked will be set to 0 and -EBUSY returned.
*/
static int faultin_page(struct vm_area_struct *vma,
unsigned long address, unsigned int *flags, bool unshare,
int *locked)
{
...
/*
* The VM_FAULT_WRITE bit tells us that do_wp_page has broken COW when
* necessary, even if maybe_mkwrite decided not to set pte_write. We
* can thus safely do subsequent page lookups as if they were reads.
* But only do so when looping for pte_write is futile: in some cases
* userspace may also be wanting to write to the gotten user page,
* which a read fault here might prevent (a readonly page might get
* reCOWed by userspace write).
*/
if ((ret & VM_FAULT_WRITE) && !(vma->vm_flags & VM_WRITE))
*flags |= FOLL_COW;
return 0;
}
L'opération OU avec FOLL_COW dans la fonction faultin_page peut être utilisée ; elle fait partie du correctif pour Dirty COW.
static long __get_user_pages(struct mm_struct *mm,
unsigned long start, unsigned long nr_pages,
unsigned int gup_flags, struct page **pages,
struct vm_area_struct **vmas, int *locked)
{
...
retry:
/*
* If we have a pending SIGKILL, don't keep faulting pages and
* potentially allocating memory.
*/
if (fatal_signal_pending(current)) {
ret = -EINTR;
goto out;
}
cond_resched();
page = follow_page_mask(vma, start, foll_flags, &ctx); // can_follow_write_pte
if (!page || PTR_ERR(page) == -EMLINK) {
ret = faultin_page(vma, start, &foll_flags,
PTR_ERR(page) == -EMLINK, locked); // flags |= FOLL_COW
switch (ret) {
case 0:
goto retry; // try follow page again
case -EBUSY:
case -EAGAIN:
ret = 0;
fallthrough;
case -EFAULT:
case -ENOMEM:
case -EHWPOISON:
goto out;
}
BUG();
} else if (PTR_ERR(page) == -EEXIST) {
...
}
Afin d'atteindre la fonction can_follow_write_pte avec le flag FOLL_COW activé, vous devez appeler la fonction faultin_page au sein de la fonction __get_user_pages, rediriger vers l'étiquette retry, puis appeler à nouveau la fonction faultin_page.
pte_dirty
Cette condition peut être satisfaite grâce au commit suivant : https://github.com/torvalds/linux/commit/9ae0f87d009ca6c4aab2882641ddfc319727e3db
/*
* Install PTEs, to map dst_addr (within dst_vma) to page.
*
* This function handles both MCOPY_ATOMIC_NORMAL and _CONTINUE for both shmem
* and anon, and for both shared and private VMAs.
*/
int mfill_atomic_install_pte(struct mm_struct *dst_mm, pmd_t *dst_pmd,
struct vm_area_struct *dst_vma,
unsigned long dst_addr, struct page *page,
bool newly_allocated, bool wp_copy)
{
...
_dst_pte = mk_pte(page, dst_vma->vm_page_prot);
_dst_pte = pte_mkdirty(_dst_pte); // set pte dirty unconditionally
if (page_in_cache && !vm_shared)
writable = false;
...
}
Grâce au correctif ci-dessus, il est possible d'installer une pte pour la page mémoire partagée en lecture seule dans un état dirty, sans condition.
Si ces trois flags sont satisfaits, la fonction can_follow_write_pte retournera true.
static struct page *follow_page_pte(struct vm_area_struct *vma,
unsigned long address, pmd_t *pmd, unsigned int flags,
struct dev_pagemap **pgmap)
{
...
// true && !true == false
if ((flags & FOLL_WRITE) && !can_follow_write_pte(pte, flags)) {
pte_unmap_unlock(ptep, ptl);
return NULL;
}
page = vm_normal_page(vma, address, pte); // get read-only shared memory page
...
out:
pte_unmap_unlock(ptep, ptl);
return page;
no_page:
pte_unmap_unlock(ptep, ptl);
if (!pte_none(pte))
return NULL;
return no_page_table(vma, flags);
}
La fonction follow_page_pte retourne la page mémoire partagée en lecture seule.
Le scénario de course suivant est démontré par le PoC, qui sera présenté plus loin.
| madvise et lecture | ioctl UFFDIO_CONTINUE | pwrite |
|---|---|---|
| madvise // supprime la page | ||
| shmem_fault // défaut de lecture | ||
| handle_userfault | ||
| userfaultfd_continue | ||
| mcontinue_atomic_pte | ||
| ret = shmem_getpage(inode, pgoff, &page, SGP_NOALLOC); // obtient la page | ||
| mfill_atomic_install_pte | ||
| _dst_pte = pte_mkdirty(_dst_pte); // rend la pte dirty | ||
| set_pte_at(dst_mm, dst_addr, dst_pte, _dst_pte); // installe la pte | mem_rw | |
| access_remote_vm // avec FOLL_FORCE | ||
| __get_user_pages | ||
| can_follow_write_pte // pas de FOLL_COW, retourne 0 | ||
| faultin_page | ||
| flags | ||
| retry: | ||
| follow_page_pte | ||
| madvise // supprime la page | ||
| shmem_fault // défaut de lecture | ||
| handle_userfault | ||
| userfaultfd_continue | ||
| mcontinue_atomic_pte | ||
| ret = shmem_getpage(inode, pgoff, &page, SGP_NOALLOC); // obtient la page | ||
| mfill_atomic_install_pte | ||
| _dst_pte = pte_mkdirty(_dst_pte); // rend la pte dirty | ||
| set_pte_at(dst_mm, dst_addr, dst_pte, _dst_pte); // installe la pte | ||
| can_follow_write_pte // retourne 1 | ||
| copy_to_user(buf, page, this_len) // écrit le contenu dans la page en lecture seule |
Les routines madvise et read sont exécutées deux fois ; la première exécution induit un retry de __get_user_pages, et la seconde exécution amène follow_page_pte à retourner la page pointée par la pte qui a été rendue dirty par mfill_atomic_install_pte.
Vous pouvez consulter le reproducer de David Hildenbrand au lien ci-dessus.
Cependant, comme le reproducer rend difficile la reconnaissance du scénario de course exact, j'ai modifié le reproducer pour privilégier une exécution plus linéaire et, en conséquence, j'ai également modifié le code source du noyau Linux.
.config:
...
CONFIG_USERFAULTFD=y
CONFIG_HAVE_ARCH_USERFAULTFD_WP=y
CONFIG_HAVE_ARCH_USERFAULTFD_MINOR=y
...
PoC : (poc.c)
Correctif : (poc_deayzl.patch)
Pour les tests, les étapes préliminaires suivantes doivent être effectuées. (Les instructions du reproducer de David indiquent que le chemin du fichier est /tmp/foo. Mais tmpfs n'est pas une mémoire partagée dans la version actuelle, donc l'enregistrement userfaultfd échoue)
sudo -s
echo "asdf" > /dev/shm/foo
chmod 0404 /dev/shm/foo
exit
Après l'exécution du PoC, vous verrez l'écran suivant.

lore.kernel patch v1 : https://lore.kernel.org/linux-mm/[email protected]/#r
lore.kernel patch v2 : https://lore.kernel.org/all/[email protected]/T/#u