Trik ELF di Go


Dalam catatan ini, kita akan belajar cara mendapatkan kode mesin dari fungsi Go secara langsung dalam runtime, mencetaknya menggunakan disassembler, dan di sepanjang jalan kita akan menemukan beberapa trik seperti mendapatkan alamat fungsi tanpa memanggilnya.


Peringatan : artikel mini ini tidak akan mengajarkan Anda sesuatu yang berguna.


Nilai fungsi di Go


Pertama, mari kita tentukan apa fungsi Go dan mengapa kita membutuhkan konsep nilai fungsi .


Ini paling baik dijelaskan oleh dokumen Panggilan Fungsi Go 1.1 . Dokumen ini bukan baru, tetapi sebagian besar informasi di dalamnya masih relevan.


Pada level terendah, selalu merupakan pointer ke kode yang dapat dieksekusi, tetapi ketika kita menggunakan fungsi / penutupan anonim atau meneruskan fungsi sebagai interface{} , pointer ini tersembunyi di dalam beberapa struktur.


Nama fungsi itu sendiri bukan ekspresi, oleh karena itu, kode tersebut tidak berfungsi:


 // https://play.golang.org/p/wXeVLU7nLPs package main func add1(x int) int { return 1 } func main() { addr := &add1 println(addr) } 

compile: cannot take the address of add1

Tetapi pada saat yang sama kita bisa mendapatkan function value melalui nama fungsi yang sama:


 // https://play.golang.org/p/oWqv_FQq4hy package main func add1(x int) int { return 1 } func main() { f := add1 // <-------- addr := &f println(addr) } 

Kode ini diluncurkan, tetapi akan mencetak alamat variabel lokal pada stack, yang tidak persis seperti yang kita inginkan. Tetapi, seperti yang disebutkan di atas, alamat fungsi masih ada, Anda hanya perlu tahu cara mengaksesnya.


Paket reflect tergantung pada detail implementasi ini untuk berhasil menjalankan reflect.Value.Call() . Di sana (mencerminkan / makefunc.go) Anda dapat memata-matai langkah selanjutnya untuk mendapatkan alamat fungsi:


 dummy := makeFuncStub code := **(**uintptr)(unsafe.Pointer(&dummy)) 

Kode di atas menunjukkan gagasan dasar bahwa Anda dapat memperbaiki ke suatu fungsi:


 // funcAddr returns function value fn executable code address. func funcAddr(fn interface{}) uintptr { // emptyInterface is the header for an interface{} value. type emptyInterface struct { typ uintptr value *uintptr } e := (*emptyInterface)(unsafe.Pointer(&fn)) return *e.value } 

add1 fungsi add1 dapat add1 dengan memanggil funcAddr(add1) .


Mendapatkan blok kode fungsi mesin


Sekarang kita memiliki alamat awal kode mesin fungsi, kami ingin mendapatkan seluruh kode mesin fungsi. Di sini Anda harus dapat menentukan di mana kode fungsi saat ini berakhir.


Jika arsitektur x86 memiliki instruksi panjang tetap, itu tidak akan terlalu sulit dan beberapa heuristik dapat membantu kami, di antaranya:


  • Sebagai aturan, pada akhir kode fungsi ada pemukulan dari instruksi INT3 . Ini adalah penanda yang baik untuk akhir kode fungsi, tetapi mungkin tidak ada.
  • Fungsi dengan frame non-nol untuk stack memiliki prolog yang memeriksa apakah stack ini perlu diperluas. Jika ya, maka lompatan ke kode dilakukan segera setelah kode fungsi, dan kemudian lompatan ke awal fungsi. Kode yang kami minati akan berada di tengah.

Tetapi Anda harus secara jujur โ€‹โ€‹mendekode instruksi, karena byte-by-pass dapat menemukan byte INT3 di dalam instruksi lain. Menghitung panjang instruksi untuk dilewati juga tidak mudah, karena itu x86, sayang .


Alamat suatu fungsi dalam konteks paket runtime kadang-kadang disebut PC , untuk menekankan kemampuan untuk menggunakan alamat di suatu tempat di dalam fungsi, dan bukan hanya titik masuk dari fungsi. Hasil funcAddr dapat digunakan sebagai argumen untuk fungsi runtime.FuncForPC() untuk mendapatkan runtime.Func tanpa memanggil fungsi itu sendiri. Melalui transformasi Tahun Baru yang tidak aman, kita dapat mengakses runtime._func , yang informatif, tetapi tidak terlalu berguna: tidak ada informasi tentang ukuran blok kode fungsi.


Tampaknya tanpa bantuan ELF kita tidak bisa mengatasinya.


Untuk platform di mana executable memiliki format berbeda, sebagian besar artikel akan tetap relevan, tetapi Anda harus menggunakan bukan debug/elf , tetapi paket lain dari debug .

ELF yang bersembunyi di program Anda


Informasi yang kami butuhkan sudah terkandung dalam metadata file ELF .


Melalui os.Args[0] kita dapat mengakses file yang dapat dieksekusi itu sendiri, dan sudah mendapatkan tabel simbol darinya.


 func readELF() (*elf.File, error) { f, err := os.Open(os.Args[0]) if err != nil { return nil, fmt.Errorf("open argv[0]: %w", err) } return elf.NewFile(f) } 

Cari karakter di dalam elf.File


Semua karakter dapat File.Symbols() menggunakan metode File.Symbols() . Metode ini mengembalikan []elf.Symbol , yang berisi bidang Symbol.Size - ini adalah "ukuran fungsi" yang kita Symbol.Size . Bidang Symbol.Value harus cocok dengan nilai yang dikembalikan oleh funcAddr .


Anda dapat mencari simbol yang diinginkan baik dengan alamat ( Symbol.Value ) atau dengan nama ( Symbol.Name ). Jika karakter diurutkan berdasarkan nama, dimungkinkan untuk menggunakan sort.Search() , tetapi ini tidak begitu:


Simbol akan dicantumkan sesuai urutan yang muncul dalam file.

Jika Anda sering perlu menemukan karakter dalam tabel, Anda harus membuat indeks tambahan, misalnya, melalui map[string]*elf.Symbol atau map[uintptr]*elf.Symbol .


Karena kita sudah tahu cara mendapatkan alamat suatu fungsi berdasarkan nilainya, kita akan mencarinya:


 func elfLookup(f *elf.File, value uint64) *elf.Symbol { symbols, err := f.Symbols() if err != nil { return nil } for _, sym := range symbols { if sym.Value == value { return &sym } } return nil } 

Catatan : agar pendekatan ini berfungsi, kita membutuhkan tabel karakter. Jika biner dibangun dengan ` -ldflags "-s" ', maka elfLookup() akan selalu mengembalikan nil . Jika Anda menjalankan program melalui go run Anda dapat mengalami masalah yang sama. Untuk contoh dari artikel ini, disarankan untuk melakukan ' go build ' atau ' go install ' untuk mendapatkan file yang dapat dieksekusi.

Mendapatkan kode fungsi mesin


Mengetahui kisaran alamat di mana kode yang dapat dieksekusi terletak, tetap hanya untuk menariknya dalam bentuk []byte untuk pemrosesan yang mudah.


 func funcCode(addr uintptr) ([]byte, error) { elffile, err := readELF() if err != nil { return nil, fmt.Errorf("read elf: %w", err) } sym := elfLookup(elffile, uint64(addr)) if sym == nil { return nil, fmt.Errorf("can't lookup symbol for %x", addr) } code := *(*[]byte)(unsafe.Pointer(&reflect.SliceHeader{ Data: addr, Len: int(sym.Size), Cap: int(sym.Size), })) return code, nil } 

Kode ini sengaja disederhanakan untuk demonstrasi. Anda tidak harus membaca ELF setiap kali dan melakukan pencarian linear di atas meja.


Hasil dari fungsi funcCode() adalah irisan dengan byte dari kode fungsi mesin. Dia harus funcAddr() hasil pemanggilan funcAddr() .


 code, err := funcCode(funcAddr(add1)) if err != nil { log.Panicf("can't get function code: %v", err) } fmt.Printf("% x\n", code) // => 48 8b 44 24 08 48 ff c0 48 89 44 24 10 c3 

Membongkar kode mesin


Untuk membuat kode mesin lebih mudah dibaca, kami akan menggunakan disassembler.


Saya paling akrab dengan proyek zydis dan Intel XED , jadi pertama-tama pilihan saya jatuh pada mereka.


Untuk Go, Anda dapat mengambil go-zydis binding , yang cukup bagus dan mudah dipasang untuk tugas kami.


Mari kita gambarkan abstraksi "melewati instruksi mesin", dengan bantuan yang memungkinkan untuk mengimplementasikan operasi lain:


 func walkDisasm(code []byte, visit func(*zydis.DecodedInstruction) error) error { dec := zydis.NewDecoder(zydis.MachineMode64, zydis.AddressWidth64) buf := code for len(buf) > 0 { instr, err := dec.Decode(buf) if err != nil { return err } if err := visit(instr); err != nil { return err } buf = buf[int(instr.Length):] } return nil } 

Fungsi ini mengambil irisan kode mesin sebagai input dan memanggil fungsi callback untuk setiap instruksi yang diterjemahkan.


Berdasarkan itu, kita dapat menulis printDisasm kita printDisasm :


 func printDisasm(code []byte) error { const ZYDIS_RUNTIME_ADDRESS_NONE = math.MaxUint64 formatter, err := zydis.NewFormatter(zydis.FormatterStyleIntel) if err != nil { return err } return walkDisasm(code, func(instr *zydis.DecodedInstruction) error { s, err := formatter.FormatInstruction(instr, ZYDIS_RUNTIME_ADDRESS_NONE) if err != nil { return err } fmt.Println(s) return nil }) } 

Jika kita menjalankan printDisasm pada add1 fungsi add1 , kita mendapatkan hasil yang sudah lama ditunggu-tunggu:


 mov rax, [rsp+0x08] inc rax mov [rsp+0x10], rax ret 

Validasi hasil


Sekarang kita akan mencoba memastikan bahwa kode assembler yang diperoleh di bagian sebelumnya sudah benar.


Karena kami sudah memiliki biner terkompilasi, Anda dapat menggunakan objdump disertakan dengan Go:


 $ go tool objdump -s 'add1' exe TEXT main.add1(SB) example.go example.go:15 0x4bb760 488b442408 MOVQ 0x8(SP), AX example.go:15 0x4bb765 48ffc0 INCQ AX example.go:15 0x4bb768 4889442410 MOVQ AX, 0x10(SP) example.go:15 0x4bb76d c3 RET 

Semuanya bertemu, hanya sintaks yang sedikit berbeda, yang diharapkan.


Ekspresi metode


Jika kita perlu melakukan hal yang sama dengan metode, maka alih-alih nama fungsi kita akan menggunakan ekspresi metode .


Katakanlah add1 kami add1 bukan fungsi, tetapi metode jenis adder :


 type adder struct{} func (adder) add1(x int) int { return x + 2 } 

Maka panggilan untuk mendapatkan alamat fungsi akan terlihat seperti funcAddr(adder.add1) .


Kesimpulan


Saya sampai pada hal-hal ini bukan karena kebetulan dan, mungkin, dalam salah satu artikel berikut ini saya akan memberi tahu Anda bagaimana rencananya akan menggunakan semua mekanisme ini. Sementara itu, saya sarankan memperlakukan catatan ini sebagai deskripsi dangkal tentang bagaimana runtime dan reflect melihat fungsi Go kami melalui nilai fungsi.


Daftar sumber daya yang digunakan:


Source: https://habr.com/ru/post/id482392/


All Articles