الحيل ELF في الذهاب


في هذه المقالة ، سوف نتعلم كيفية الحصول على رمز الجهاز لوظيفة Go مباشرة في وقت التشغيل ، وطباعتها باستخدام أداة فك تجميع ، وعلى طول الطريق سنكتشف العديد من الحيل مثل الحصول على عنوان وظيفة دون الاتصال بها.


تحذير : هذه المقالة المصغرة لن تعلمك أي شيء مفيد.


قيمة الوظيفة في الذهاب


أولاً ، دعونا نحدد وظيفة Go ولماذا نحتاج إلى مفهوم قيمة الوظيفة .


وأفضل تفسير لهذا هو مستند Go 1.1 Function Calls . المستند ليس جديدًا ، لكن معظم المعلومات الموجودة فيه ما زالت صالحة.


عند أدنى مستوى ، يكون مؤشرًا دائمًا إلى رمز قابل للتنفيذ ، لكن عندما نستخدم وظائف / عمليات إغلاق مجهولة أو نمرر وظيفة interface{} ، يكون هذا المؤشر مخفيًا داخل بعض الهياكل.


اسم الوظيفة بحد ذاته ليس تعبيرًا ، لذلك ، لا يعمل هذا الرمز:


 // 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

ولكن في نفس الوقت ، يمكننا الحصول على function value خلال نفس اسم الوظيفة:


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

يتم تشغيل هذا الرمز ، لكنه سيطبع عنوان المتغير المحلي على الحزمة ، وهذا ليس بالضبط ما أردنا. ولكن ، كما ذكر أعلاه ، لا يزال عنوان الوظيفة موجودًا ، تحتاج فقط إلى معرفة كيفية الوصول إليها.


تعتمد حزمة reflect على تفاصيل التنفيذ هذه لتنفيذ reflect.Value.Call() بنجاح. هناك (reflect / makefunc.go) يمكنك تجسس الخطوة التالية للحصول على عنوان الوظيفة:


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

يوضح الرمز أعلاه فكرة أساسية يمكنك صقلها إلى وظيفة:


 // 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 وظيفة add1 عن طريق استدعاء funcAddr(add1) .


الحصول على كتلة من رمز وظيفة الجهاز


الآن وبعد أن أصبح لدينا عنوان بداية رمز الجهاز الخاص بالوظيفة ، نود الحصول على رمز الجهاز بالكامل للوظيفة. هنا تحتاج إلى أن تكون قادرًا على تحديد مكان انتهاء رمز الوظيفة الحالية.


إذا كانت بنية x86 تحتوي على تعليمات بطول ثابت ، فلن يكون الأمر صعبًا للغاية وقد تساعدنا العديد من الاستدلال ، من بينها:


  • كقاعدة عامة ، في نهاية رمز الوظيفة هناك ضرب من تعليمات INT3 . هذه علامة جيدة لنهاية رمز الوظيفة ، لكنها قد تكون مفقودة.
  • وظائف مع إطار غير الصفر للمكدس لها prolog يتحقق ما إذا كان هذا المكدس يحتاج إلى توسيع. إذا كانت الإجابة بنعم ، ثم يتم تنفيذ قفزة إلى الرمز مباشرة بعد رمز الوظيفة ، ثم قفزة إلى بداية الوظيفة. الكود الذي نحن مهتمون به سيكون في المنتصف.

لكن ستحتاج إلى فك شفرة التعليمات بصدق ، لأن البايتة يمكن أن تجد البايتة INT3 داخل إرشادات أخرى. إن حساب طول التعليمات لتخطيها ليس بالأمر السهل ، لأنه x86 ، رضيع .


أحيانًا ما يطلق على عنوان وظيفة ما في سياق حزمة runtime اسم PC ، للتأكيد على القدرة على استخدام العنوان في مكان ما داخل الوظيفة ، وليس فقط نقطة إدخال الوظيفة. يمكن استخدام نتيجة funcAddr كوسيطة إلى وقت runtime.FuncForPC() وظيفة runtime.FuncForPC() للحصول على runtime.Func دون استدعاء الوظيفة نفسها. من خلال تحويلات رأس السنة الجديدة غير الآمنة ، يمكننا الوصول إلى runtime._func ، وهو أمر مفيد ، ولكنه ليس مفيدًا للغاية: لا توجد معلومات حول حجم كتلة رمز الوظيفة.


يبدو أنه بدون مساعدة ELFs لا يمكننا التعامل.


بالنسبة إلى الأنظمة الأساسية التي يكون للملفات القابلة للتنفيذ فيها تنسيق مختلف ، ستظل معظم المقالة ذات صلة ، ولكن ستحتاج إلى استخدام لا debug/elf ، ولكن هناك حزمة أخرى من debug .

ELF الذي يختبئ في البرنامج الخاص بك


المعلومات التي نحتاجها موجودة بالفعل في البيانات الوصفية لملف ELF .


من خلال os.Args[0] يمكننا الوصول إلى الملف القابل للتنفيذ نفسه ، والحصول بالفعل على جدول الرموز منه.


 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) } 

البحث عن شخصية داخل elf.File


يمكن File.Symbols() كل الحروف باستخدام طريقة File.Symbols() . تقوم هذه الطريقة بإرجاع []elf.Symbol ، والذي يحتوي على حقل Symbol.Size - وهذا هو "حجم الوظيفة" الذي Symbol.Size . يجب أن يتطابق حقل Symbol.Value مع القيمة التي يتم إرجاعها بواسطة funcAddr .


يمكنك البحث عن الرمز المطلوب إما عن طريق العنوان ( Symbol.Value ) أو بالاسم ( Symbol.Name ). إذا تم فرز الأحرف حسب الاسم ، فسيكون من الممكن استخدام sort.Search() ، لكن هذا ليس كذلك:


سيتم سرد الرموز بالترتيب الذي تظهر به في الملف.

إذا كنت تحتاج غالبًا إلى العثور على أحرف في الجدول ، فيجب أن map[string]*elf.Symbol فهرسًا إضافيًا ، على سبيل المثال ، من خلال map[string]*elf.Symbol أو map[uintptr]*elf.Symbol .


نظرًا لأننا نعرف بالفعل كيفية الحصول على عنوان دالة حسب قيمتها ، فإننا سنبحث عنها:


 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 } 

ملاحظة : لكي يعمل هذا النهج ، نحتاج إلى جدول أحرف. إذا تم بناء الملف الثنائي -ldflags "-s" ` -ldflags "-s"elfLookup() دائمًا إلى nil . إذا قمت بتشغيل البرنامج من خلال go run فقد تواجه نفس المشكلة. للحصول على أمثلة من المقال ، يوصى بإجراء " go build " أو " go install " للحصول على الملفات القابلة للتنفيذ.

الحصول على رمز وظيفة الجهاز


معرفة نطاق العناوين التي يوجد بها الكود القابل للتنفيذ ، يبقى فقط لسحبه في شكل []byte للمعالجة المريحة.


 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 } 

تم تبسيط هذا الرمز عن قصد للعرض. يجب ألا تقرأ ELF كل مرة وتجري بحثًا خطيًا على جدولها.


نتيجة funcCode() عبارة عن شريحة ذات بايت من رمز وظيفة الجهاز. يجب عليها funcAddr() نتيجة استدعاء 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 

تفكيك كود الآلة


لتسهيل قراءة رمز الجهاز ، سنستخدم أداة فك الشفرة.


أنا أكثر دراية بمشاريع zydis و Intel XED ، لذا فأولًا ما أختارها .


بالنسبة لـ Go ، يمكنك استخدام go-zydis binding ، وهو أمر جيد بما فيه الكفاية وسهل التثبيت لمهمتنا .


دعونا نصف مجموعة من "تعليمات تجاوز الماكينة" ، التي يمكن عندها تنفيذ عمليات أخرى:


 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 } 

تأخذ هذه الوظيفة شريحة رمز الجهاز كمدخل وتدعو وظيفة رد الاتصال لكل تعليمة فك الشفرة.


بناءً على ذلك ، يمكننا كتابة printDisasm الذي 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 }) } 

إذا قمنا بتشغيل printDisasm على add1 الوظيفة add1 ، add1 على النتيجة التي طال انتظارها:


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

نتيجة التحقق من الصحة


سنحاول الآن التأكد من صحة رمز المجمّع الذي تم الحصول عليه في القسم السابق.


نظرًا لأن لدينا بالفعل برنامج ثنائي objdump ، يمكنك استخدام objdump المرفق بـ 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 

كل شيء يتقارب ، فقط بناء الجملة يختلف قليلاً ، وهو ما هو متوقع.


تعبيرات الأسلوب


إذا احتجنا إلى فعل الشيء نفسه مع الطرق ، فبدلاً من اسم الوظيفة ، سنستخدم تعبير الطريقة .


دعنا نقول أن add1 ليست وظيفة بالفعل ، ولكن طريقة نوع الإعلان:


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

ستبدو الدعوة للحصول على عنوان الوظيفة مثل funcAddr(adder.add1) .


استنتاج


لقد جئت إلى هذه الأشياء ليس عن طريق الصدفة ، وربما في إحدى المقالات التالية سوف أخبرك كيف تم التخطيط لاستخدام كل هذه الآليات. في غضون ذلك ، أقترح التعامل مع هذه المقالة باعتبارها وصفًا سطحيًا لكيفية runtime reflect في وظائف Go من خلال قيمة الوظيفة.


قائمة الموارد المستخدمة:


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


All Articles