Halo, Habr! Nama saya Denis Kopyrin, dan hari ini saya ingin berbicara tentang bagaimana kami memecahkan masalah cadangan sesuai permintaan di macOS. Bahkan, tugas menarik yang saya temui di institut akhirnya tumbuh menjadi proyek penelitian besar tentang bekerja dengan sistem file. Semua detail ada di bawah potongan.

Saya tidak akan mulai dari jauh, saya hanya bisa mengatakan bahwa itu semua dimulai dengan sebuah proyek di Institut Fisika dan Teknologi Moskow, yang saya kembangkan dengan penyelia saya di departemen dasar Acronis. Kami dihadapkan dengan tugas mengatur penyimpanan file jarak jauh, atau lebih tepatnya, mempertahankan status cadangan mereka saat ini.
Untuk memastikan keamanan data, kami menggunakan ekstensi kernel macOS, yang mengumpulkan informasi tentang peristiwa dalam sistem. KPI untuk pengembang memiliki API KAUTH, yang memungkinkan Anda menerima pemberitahuan tentang membuka dan menutup file - itu saja. Jika Anda menggunakan KAUTH, Anda harus benar-benar menyimpan file ketika membukanya untuk menulis, karena peristiwa penulisan ke file tidak tersedia untuk pengembang. Informasi seperti itu tidak cukup untuk tugas kami. Memang, untuk menambah salinan data cadangan secara permanen, Anda harus memahami persis di mana pengguna (atau malware) menulis data baru ke file tersebut.

Tapi siapa dari pengembang yang takut dengan pembatasan OS? Jika API kernel tidak memungkinkan Anda untuk mendapatkan informasi tentang operasi penulisan, maka Anda harus menemukan cara Anda sendiri untuk menyadap melalui alat kernel lainnya.
Pada awalnya, kami tidak ingin menambal inti dan strukturnya. Sebagai gantinya, mereka mencoba membuat volume virtual keseluruhan yang memungkinkan kami mencegat semua permintaan baca dan tulis yang melewatinya. Tetapi pada saat yang sama, satu fitur macOS yang tidak menyenangkan ternyata: sistem operasi percaya bahwa ia belum 1, tetapi 2 USB flash drive, dua disk, dan sebagainya. Dan dari kenyataan bahwa volume kedua berubah ketika bekerja dengan yang pertama, macOS mulai bekerja secara salah dengan drive. Ada banyak masalah dengan metode ini sehingga saya harus meninggalkannya.
Cari solusi lain
Terlepas dari keterbatasan KAUTH, KPI ini memungkinkan Anda mendapatkan pemberitahuan tentang penggunaan file untuk merekam sebelum semua operasi. Pengembang diberikan akses ke abstraksi file BSD di kernel - vnode. Anehnya, ternyata menambal vnode lebih mudah daripada menggunakan penyaringan volume. Struktur vnode memiliki tabel fungsi yang menyediakan pekerjaan dengan file nyata. Karenanya, kami memiliki ide untuk mengganti tabel ini.

Ide itu langsung dianggap sebagai ide yang baik, tetapi untuk implementasinya, perlu untuk menemukan tabel itu sendiri dalam struktur vnode, karena Apple tidak mendokumentasikan lokasinya di mana pun. Untuk melakukan ini, perlu mempelajari kode mesin kernel, dan juga untuk mencari tahu apakah mungkin untuk menulis ke alamat ini sehingga sistem tidak mati setelah itu.
Jika tabel ditemukan, kita cukup menyalinnya ke memori, ganti pointer dan tempel tautan ke tabel baru ke vnode yang ada. Berkat ini, semua operasi dengan file akan melalui driver kami, dan kami akan dapat mendaftarkan semua permintaan pengguna, termasuk membaca dan menulis. Karena itu, pencarian meja yang berharga telah menjadi tujuan utama kami.
Mengingat bahwa Apple tidak benar-benar menginginkan ini, untuk menyelesaikan masalah Anda perlu mencoba βmenebakβ lokasi tabel menggunakan heuristik untuk lokasi relatif bidang, atau mengambil fungsi yang sudah diketahui, membongkar dan mencari offset dari informasi ini.
Cara mencari offset: cara mudahCara termudah untuk menemukan offset tabel dalam vnode adalah heuristik berdasarkan lokasi bidang dalam struktur (
tautan ke Github ).
struct vnode { ... int (**v_op)(void *); mount_t v_mount; ... }
Kami akan menggunakan asumsi bahwa bidang v_op yang kami butuhkan persis 8 byte dihapus dari v_mount. Nilai yang terakhir dapat diperoleh dengan menggunakan KPI publik (
tautan ke Github ):
mount_t vnode_mount(vnode_t vp);
Mengetahui nilai v_mount, kita mulai mencari "jarum di tumpukan jerami" - kita akan melihat nilai pointer ke vnode 'vp' sebagai uintptr_t *, nilai vnode_mount (vp) sebagai uintptr_t. Ini diikuti oleh iterasi ke nilai βwajarβ dari i, sampai kondisi 'tumpukan jerami [i] == jarum' terpenuhi. Dan jika asumsi tentang lokasi bidang sudah benar, offset v_op adalah i-1.
void* getVOPPtr(vnode_t vp) { auto haystack = (uintptr_t*) vp; auto needle = (uintptr_t) vnode_mount(vp); for (int i = 0; i < ATTEMPTCOUNT; i++) { if (haystack[i] == needle) { return haystack + (i - 1); } } return nullptr; }
Cara mencari offset: pembongkaranMeskipun kesederhanaannya, metode pertama memiliki kelemahan yang signifikan. Jika Apple mengubah urutan bidang dalam struktur vnode, metode sederhana akan rusak. Metode yang lebih universal, tetapi kurang sepele adalah secara dinamis membongkar kernel.
Sebagai contoh, perhatikan fungsi kernel yang dibongkar VNOP_CREATE (
tautan ke Github ) di macOS 10.14.6. Instruksi yang menarik bagi kami ditandai dengan panah ->.
_VNOP_CREATE:
1 push rbp
2 mov rbp, rsp
3 push r15
4 push r14
5 push r13
6 push r12
7 push rbx
8 sub rsp, 0x48
9 mov r15, r8
10 mov r12, rdx
11 mov r13, rsi
-> 12 mov rbx, rdi
13 lea rax, qword [___stack_chk_guard]
14 mov rax, qword [rax]
15 mov qword [rbp+-48], rax
-> 16 lea rax, qword [_vnop_create_desc] ; _vnop_create_desc
17 mov qword [rbp+-112], rax
18 mov qword [rbp+-104], rdi
19 mov qword [rbp+-96], rsi
20 mov qword [rbp+-88], rdx
21 mov qword [rbp+-80], rcx
22 mov qword [rbp+-72], r8
-> 23 mov rax, qword [rdi+0xd0]
-> 24 movsxd rcx, dword [_vnop_create_desc]
25 lea rdi, qword [rbp+-112]
-> 26 call qword [rax+rcx*8]
27 mov r14d, eax
28 test eax, eax
β¦.
errno_t VNOP_CREATE(vnode_t dvp, vnode_t * vpp, struct componentname * cnp, struct vnode_attr * vap, vfs_context_t ctx) { int _err; struct vnop_create_args a; a.a_desc = &vnop;_create_desc; a.a_dvp = dvp; a.a_vpp = vpp; a.a_cnp = cnp; a.a_vap = vap; a.a_context = ctx; _err = (*dvp->v_op[vnop_create_desc.vdesc_offset])(&a;); β¦
Kami akan memindai instruksi assembler untuk menemukan pergeseran dalam vnode dvp. "Tujuan" kode assembler adalah untuk memanggil fungsi dari tabel v_op. Untuk melakukan ini, prosesor harus mengikuti langkah-langkah ini:
- Unggah dvp untuk mendaftar
- Dereferensi untuk mendapatkan v_op (baris 23)
- Dapatkan vnop_create_desc.vdesc_offset (baris 24)
- Panggil fungsi (saluran 26)
Jika semuanya jelas dengan langkah 2-4, maka kesulitan muncul dengan langkah pertama. Bagaimana memahami register dvp mana yang dimuat? Untuk melakukan ini, kami menggunakan metode meniru fungsi yang memantau pergerakan pointer yang diinginkan. Menurut konvensi pemanggilan System V x86_64, argumen pertama dilewatkan dalam register rdi. Oleh karena itu, kami memutuskan untuk melacak semua register yang berisi rdi. Dalam contoh saya, ini adalah register rbx dan rdi. Juga, salinan register dapat disimpan di stack, yang ditemukan dalam versi debug kernel.
Mengetahui bahwa register rbx dan rdi menyimpan dvp, kita mengetahui bahwa vnode dereferensi baris 23 untuk mendapatkan v_op. Jadi kita mendapatkan asumsi bahwa perpindahan dalam struktur adalah 0xd0. Untuk mengkonfirmasi keputusan yang benar, kami terus memindai dan memastikan bahwa fungsi tersebut dipanggil dengan benar (baris 24 dan 26).
Metode ini lebih aman, tetapi, sayangnya, ia juga memiliki kelemahan. Kita harus mengandalkan fakta bahwa pola fungsi (yaitu 4 langkah yang kita bicarakan di atas) akan sama. Namun, probabilitas mengubah pola fungsi adalah urutan besarnya kurang dari probabilitas mengubah urutan bidang. Jadi kami memutuskan untuk berhenti pada metode kedua.
Ganti pointer dalam tabel
Setelah menemukan v_op, muncul pertanyaan, bagaimana cara menggunakan pointer ini? Ada dua cara berbeda - menimpa fungsi dalam tabel (panah ketiga pada gambar) atau menimpa tabel dalam vnode (panah kedua pada gambar).
Pada awalnya sepertinya opsi pertama lebih menguntungkan, karena kita hanya perlu mengganti satu pointer. Namun, pendekatan ini memiliki 2 kelemahan signifikan. Pertama, tabel v_op adalah sama untuk semua vnode dari sistem file yang diberikan (v_op untuk HFS +, v_op untuk APFS, ...), jadi diperlukan penyaringan dengan vnode, yang bisa sangat mahal - Anda harus menyaring vnode tambahan pada setiap operasi penulisan. Kedua, tabel ditulis pada halaman Read-Only. Batasan ini dapat dielakkan jika Anda menggunakan rekaman melalui IOMappedWrite64, melewati pemeriksaan sistem. Juga, jika kext dengan driver sistem file dikirimkan, akan sulit untuk mengetahui cara menghapus tambalan.
Opsi kedua ternyata lebih bertarget dan aman - pencegat akan dipanggil hanya untuk vnode yang diperlukan, dan memori vnode pada awalnya memungkinkan operasi Baca-Tulis. Karena seluruh tabel sedang diganti, perlu untuk mengalokasikan lebih banyak memori (80 fungsi daripada satu). Dan karena jumlah tabel biasanya sama dengan jumlah sistem file, batas memori benar-benar dapat diabaikan.
Itulah sebabnya kext menggunakan metode kedua, meskipun, saya ulangi, sekilas tampaknya opsi ini lebih buruk.

Akibatnya, pengemudi kami bekerja sebagai berikut:
- API KAUTH menyediakan vnode
- Kami mengganti tabel vnode. Jika diperlukan, kami mencegat operasi hanya untuk vnode "menarik", misalnya, dokumen pengguna
- Saat mencegat, kami memeriksa proses mana yang direkam, kami memfilter "milik kami"
- Kami mengirimkan permintaan UserSpace yang sinkron kepada klien, yang memutuskan apa yang sebenarnya perlu disimpan.
Apa yang terjadi
Hari ini kami memiliki modul eksperimental, yang merupakan perluasan dari kernel macOS dan memperhitungkan setiap perubahan pada sistem file di tingkat granular. Perlu dicatat bahwa di macOS 10.15 Apple memperkenalkan kerangka kerja baru (
tautan ke EndpointSecurity ) untuk menerima pemberitahuan perubahan pada sistem file, yang direncanakan untuk digunakan dalam Perlindungan Aktif, oleh karena itu solusi yang dijelaskan dalam artikel tersebut dinyatakan usang.