قليلا عن الترحيل في نواة لينكس

سنقوم بحل مشكلة بسيطة - حدد كتلة ذاكرة في مساحة نواة لينكس ، ووضع بعض التعليمات البرمجية الثنائية فيها وتنفيذها. للقيام بذلك ، نكتب وحدة kernel ، حيث نحدد الوظيفة foo ، والتي ستلعب دور الشفرة الثنائية التي نحتاجها ، ثم باستخدام وظيفة module_alloc ، حدد كتلة الذاكرة ، انسخ هذه الوظيفة بأكملها إليها من خلال memcpy ونمنحها التحكم.

إليك ما يبدو عليه:

static noinline int foo(int ret)
{
	return (ret + 2);
}

static int exe_init(void)
{
	int ret = 0;
	int (*new_foo)(int);

	ret = foo(0);
	printk(KERN_INFO "ret=%d\n", ret);

	new_foo = module_alloc(PAGE_SIZE);
	set_memory_x((unsigned long)new_foo, 1);

	printk(KERN_INFO "foo=%lx new_foo=%lx\n",
		(unsigned long)foo, (unsigned long)new_foo);

	memcpy((void *)new_foo, (const void *)foo, PAGE_SIZE);

	ret = new_foo(1);
	printk(KERN_INFO "ret=%d\n", ret);

	vfree(new_foo);
	return 0;
}

يتم استدعاء الدالة exe_init عند تحميل الوحدة. ننظر إلى نتيجة العمل في سجل النواة:

[ 6972.522422] ret=2
[ 6972.522443] foo=ffffffffc0000000 new_foo=ffffffffc0007000
[ 6972.522457] ret=3

كل شيء يعمل بشكل صحيح. والآن نضيف دالة printk إلى foo لعرض الوسيطة:

static noinline int foo(int ret)
{
	printk(KERN_INFO "ret=%d\n", ret);
	return (ret + 2);
}

وتفريغ 25 بايت من محتويات الدالة new_foo () قبل تمرير التحكم إليها:

	memcpy((void *)new_foo, (const void *)foo, PAGE_SIZE);
	dump((unsigned long)new_foo);

يعرف التفريغ على أنه

static inline void dump(unsigned long x)
{
	int i;
	for (i = 0; i < 25; i++) \
		pr_cont("%.2x ", *((unsigned char *)(x) + i) & 0xFF); \
	pr_cont("\n");
}

نحمل الوحدة ونحصل على عطل بالرسالة التالية في السجل:

[ 8482.806092] ret=0
[ 8482.806092] ret=2
[ 8482.806111] foo=ffffffffc0000000 new_foo=ffffffffc0007000
[ 8482.806113] 53 89 fe 89 fb 48 c7 c7 24 10 00 c0 e8 e8 3d 0b c1 8d 43 02 5b c3 66 2e 0f 
[ 8482.806135] invalid opcode: 0000 [#1] SMP NOPTI
[ 8482.806639] CPU: 0 PID: 5081 Comm: insmod Tainted: G           O      5.4.27 #12
[ 8482.807669] Hardware name: innotek GmbH VirtualBox/VirtualBox, BIOS VirtualBox 12/01/2006
[ 8482.808560] RIP: 0010:irq_create_direct_mapping+0x79/0x90

بطريقة ما ، انتهى بنا الأمر في وظيفة irq_create_direct_mapping ، على الرغم من أنه كان علينا استدعاء printk. دعونا نكتشف ما حدث.

أولاً ، ألق نظرة على قائمة وظيفة foo المفككة. احصل عليه باستخدام الأمر objdump -d:

Disassembly of section .text:

0000000000000000 <foo>:
   0:	53                   	push   %rbx
   1:	89 fe                	mov    %edi,%esi
   3:	89 fb                	mov    %edi,%ebx
   5:	48 c7 c7 00 00 00 00 	mov    $0x0,%rdi
   c:	e8 00 00 00 00       	callq  11 <foo+0x11>
  11:	8d 43 02             	lea    0x2(%rbx),%eax
  14:	5b                   	pop    %rbx
  15:	c3                   	retq   
  16:	66 2e 0f 1f 84 00 00 	nopw   %cs:0x0(%rax,%rax,1)
  1d:	00 00 00 

توجد وظيفة foo في بداية قسم النص. عند الإزاحة 0xC ، يوجد كود تشغيل الأمر call call e8 - قريب ، لأنه يتم تنفيذه في مقطع الرمز الحالي ، لا تتغير قيمة المحدد. البايتات الأربعة التالية هي الإزاحة بالنسبة للقيمة في سجل RIP التي سيتم نقل عنصر التحكم إليها ، أي RIP = RIP + الإزاحة ، وفقًا لوثائق Intel (دليل مطوري برامج Intel 64 و IA-32 ، مرجع مجموعة التعليمات AZ):
يتم تحديد الإزاحة النسبية (rel16 أو rel32) بشكل عام كتسمية في رمز التجميع. ولكن على مستوى رمز الجهاز ، يتم ترميزه كقيمة فورية موقعة ، 16 أو 32 بت.
تتم إضافة هذه القيمة إلى القيمة الموجودة في سجل EIP (RIP). في وضع 64 بت ، يكون الإزاحة النسبية دائمًا قيمة فورية 32 بت والتي يتم تسجيلها لتوسيعها إلى 64 بت قبل إضافتها إلى القيمة في سجل RIP لحساب الهدف.

نحن نعرف عنوان الدالة foo ، وهو 0xffffffffc0000000 ، لذلك في RIP = 0xffffffffc0000000 + 0xc + 0x5 = 0xffffffffc00000011 (0xc هو الإزاحة إلى تعليمات e8 و 1 بايت من التعليمات و 4 بايت من الإزاحة). نحن نعرف الإزاحة ، لأن وظائف الجسم الملقاة. دعنا نحسب المكان الذي سترسل فيه المكالمة لإرسالنا إلى الوظيفة foo:

0xffffffffc00000011 + 0xffffffffc10b3de8 = 0xffffffff810b3df9

هذا هو عنوان وظيفة printk:
# cat /proc/kallsyms | grep ffffffff810b3df9  
ffffffff810b3df9 T printk

والآن نفس الشيء ينطبق على new_foo ، وعنوانه 0xffffffffc0007000

0xffffffffc0007011 + 0xffffffffc10b3de8 = 0xffffffff810badf9

لا يوجد مثل هذا العنوان في kallsyms ، ولكن هناك 0xffffffff810badf9 - 0x79 = 0xffffffff810bad80
# cat /proc/kallsyms | grep ffffffff810bad80
ffffffff810bad80 T irq_create_direct_mapping

هذه هي الوظيفة التي حدث فيها الحادث.

لمنع حدوث عطل ، ما عليك سوى إعادة حساب الإزاحة ، مع العلم بعنوان وظيفة new_foo:

memcpy((void *)new_foo, (const void *)foo, PAGE_SIZE);
unsigned int delta = (unsigned long)printk - (unsigned long)new_foo - 0x11;
*(unsigned int *)((void *)new_foo + 0xD) = delta;

بعد هذا التصحيح ، لن يكون هناك أي تعطل ، سيتم تنفيذ وظيفة new_foo والتحكم في الإرجاع بنجاح.

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

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

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

يمكنك الاطلاع على محتويات سجلات النقل باستخدام الأداة objdump مع رمز التبديل -r.
من القائمة المفككة ، نعلم أنه عند الإزاحة 0xD ، من الضروري كتابة عنوان وظيفة printk ، لذلك نبحث عن إخراج objdump بالموضع التالي:

000000000000000d R_X86_64_PC32     printk-0x0000000000000004

لذا ، لدينا سجل النقل الضروري الذي يشير إلى الموضع عند الإزاحة 0xD ، واسم الرمز الذي يجب كتابة عنوانه في هذا الموضع.

القيمة (-4). الذي يضاف إلى عنوان وظيفة printk يسمى الملحق ، ويؤخذ في الاعتبار عند حساب النتيجة النهائية للانتقال.

انظر الآن إلى رمز printk:

$ objdump -t exe.ko | grep printk
0000000000000000         *UND*	0000000000000000 printk

يوجد رمز ، غير معرف داخل الوحدة (غير محدد) ، لذلك سنبحث عنه في النواة.

سيكون من المفيد جدًا الاطلاع على سجلات النقل والرموز في شكل ثنائي. يمكن القيام بذلك باستخدام wireshark ، ويمكنه تحليل تنسيق ELF. هذا هو إدخال النقل الخاص بنا (لصق النسخ من writeshark ، LSB على اليسار):

  0d 00 00 00 00 00 00 00  02 00 00 00 22 00 00 00  fc ff ff ff ff ff ff ff
  |                     |  |          ||         |  |                     |
  +----  -------+  +--  ---++---+  +---- addendum  ------+

قارن هذا الإدخال بتعريف البنية المقابلة من <linux / elf.h>:

typedef struct elf64_rela {
  Elf64_Addr r_offset;	/* Location at which to apply the action */
  Elf64_Xword r_info;	/* index and type of relocation */
  Elf64_Sxword r_addend;	/* Constant addend used to compute value */
} Elf64_Rela;

هنا لدينا 8 بايت إزاحة 0x00000000d ، نوع 4 بايت 0x00000002 ، فهرس 4 بايت في جدول الأحرف 0x00000022 (أو 34 في العلامة العشرية) وملحق 8 بايت -4.

وهنا الإدخال من جدول الرموز في رقم 34:

  01 01 00 00 10 00 00 00  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00

والهيكل ذات الصلة

typedef struct elf64_sym {
  Elf64_Word st_name;		/* Symbol name, index in string tbl */
  unsigned char	st_info;	/* Type and binding attributes */
  unsigned char	st_other;	/* No defined meaning, 0 */
  Elf64_Half st_shndx;		/* Associated section index */
  Elf64_Addr st_value;		/* Value of the symbol */
  Elf64_Xword st_size;		/* Associated symbol size */
} Elf64_Sym;

أول 4 بايت 0x00000101 هو الفهرس في جدول السلاسل. strtab لاسم هذا الحرف ، أي برينتك. يحدد حقل st_info نوع الرمز ، ويمكن أن يكون دالة ، أو كائن بيانات ، وما إلى ذلك ، راجع مواصفات ELF لمزيد من التفاصيل. سوف نتخطى الحقل st_other ، الآن لا يهمنا ، ونلقي نظرة على الحقول الثلاثة الأخيرة st_shndx و st_value و st_size. st_shndx - فهرس رأس القسم الذي يتم تعريف الحرف فيه. نرى هنا قيمة صفرية ، لأن لم يتم تعريف الرمز داخل الوحدة النمطية ؛ فهو ليس في الأقسام المتاحة.
وبناء على ذلك ، فإن قيمة st_value وحجم st_size هي أيضًا صفر. ستملأ النواة هذه الحقول عند تحميل الوحدة.

للمقارنة ، انظر إلى الرمز foo ، الموجود بوضوح:

  08 00 00 00 02 00 02 00  00 00 00 00 00 00 00 00  16 00 00 00 00 00 00 00

يحدد الرمز دالة موجودة في قسم .text في العنوان المتعلق ببداية قسم 0x00000000 ، أي في بداية القسم ، كما رأينا في القائمة المفككة ، حجم الوظيفة هو 22 بايت.

سيعرض لنا Objdump نفس المعلومات حول هذا:

$ objdump -t exe.ko | grep foo
0000000000000000 l     F .text	0000000000000016 foo

عندما يقوم kernel بتحميل الوحدة ، فإنه يجد كل الأحرف غير المعرفة ويملأ حقلي st_value و st_size بقيم صالحة. يتم ذلك في دالة simpleify_symbols ، ملف kernel / module.c:

/* Change all symbols so that st_value encodes the pointer directly. */
static int simplify_symbols(struct module *mod, const struct load_info *info)
{
...

في معلمات الدالة ، يتم تمرير بنية load_info للنموذج التالي

struct load_info {
	const char *name;
	/* pointer to module in temporary copy, freed at end of load_module() */
	struct module *mod;
	Elf_Ehdr *hdr;
	unsigned long len;
	Elf_Shdr *sechdrs;
	char *secstrings, *strtab;
	unsigned long symoffs, stroffs, init_typeoffs, core_typeoffs;
	struct _ddebug *debug;
	unsigned int num_debug;
	bool sig_ok;
#ifdef CONFIG_KALLSYMS
	unsigned long mod_kallsyms_init_off;
#endif
	struct {
		unsigned int sym, str, mod, vers, info, pcpu;
	} index;
};

الحقول التالية تهمنا:
- hdr - رأس ملف ELF
- sechdrs - المؤشر إلى جدول رأس القسم
- strtab - جدول اسم الرمز - مجموعة من السلاسل مفصولة
بالأصفار - index.sym - فهرس رأس القسم الذي يحتوي على جدول الرموز

أولاً وقبل كل شيء ، ستحصل الوظيفة على الوصول إلى القسم مع جدول الرموز. جدول الرموز عبارة عن مجموعة من العناصر من النوع Elf64_Sym:

Elf64_Shdr *symsec = &info->sechdrs[info->index.sym];
Elf64_Sym *sym = (void *)symsec->sh_addr;

بعد ذلك ، في الحلقة ، نراجع جميع الأحرف في الجدول ، ونحدد لكل اسم:
for (i = 1; i < symsec->sh_size / sizeof(Elf_Sym); i++) {
	const char *name = info->strtab + sym[i].st_name;

يحتوي الحقل st_shndx على فهرس رأس القسم الذي يتم تعريف هذا الحرف فيه. إذا كانت هناك قيمة صفرية (حالتنا) ، فإن هذا الرمز ليس داخل الوحدة ، فأنت بحاجة إلى البحث عنه في النواة:

	switch (sym[i].st_shndx) {
	.....
	 case SHN_UNDEF: //  0
	ksym = resolve_symbol_wait(mod, info, name);
 	/* Ok if resolved.  */
	if (ksym && !IS_ERR(ksym)) {
		sym[i].st_value = kernel_symbol_value(ksym);
		break;
	}

ثم تأتي قائمة انتظار النقل في وظيفة application_relocations:

static int apply_relocations(struct module *mod, const struct load_info *info)
{
	unsigned int i;
	int err = 0;

	/* Now do relocations. */
	for (i = 1; i < info->hdr->e_shnum; i++) {
	.....

في الحلقة ، نبحث عن أقسام النقل ونعالج سجلات كل قسم موجود في دالة application_relocate_add:

if (info->sechdrs[i].sh_type == SHT_RELA) //   
	err = apply_relocate_add(info->sechdrs, info->strtab,
				info->index.sym, i, mod);

يتم تمرير مؤشر إلى جدول رأس القسم ، ومؤشر إلى جدول اسم الرمز ، وفهرس رأس قسم مع جدول رمز وفهرس رأس قسم نقل لتطبيق_relocate_add:

int apply_relocate_add(Elf64_Shdr *sechdrs,
	   const char *strtab,
	   unsigned int symindex,
	   unsigned int relsec,
	   struct module *me)
{

نتناول أولاً قسم النقل:

Elf64_Rela *rel = (void *)sechdrs[relsec].sh_addr;

ثم ، في حلقة ، نقوم بفرز مجموعة من إدخالاتها:

for (i = 0; i < sechdrs[relsec].sh_size / sizeof(*rel); i++) {

نجد قسم النقل والموضع فيه ، أي حيث نحتاج إلى إجراء تغييرات. حقل sh_info لرأس قسم النقل هو فهرس رأس قسم النقل ، وحقل r_offset من سجل النقل هو الإزاحة إلى الموضع داخل قسم النقل:

/* This is where to make the change */
loc = (void *)sechdrs[sechdrs[relsec].sh_info].sh_addr + rel[i].r_offset;

عنوان الشخصية التي سيتم استبدالها في هذا الموقف ، مع مراعاة الإضافة. يحتوي حقل r_info الخاص بإدخال إعادة التوطين على فهرس هذا الرمز في جدول الرموز:

	/* This is the symbol it is referring to.  Note that all
	   undefined symbols have been resolved.  */
	sym = (Elf64_Sym *)sechdrs[symindex].sh_addr
		+ ELF64_R_SYM(rel[i].r_info);

	val = sym->st_value + rel[i].r_addend;

يحدد نوع النقل النتيجة النهائية للحسابات ، في مثالنا هو R_X86_64_PLT32:

	switch (ELF64_R_TYPE(rel[i].r_info)) {
	......
	case R_X86_64_PLT32:	
		if (*(u32 *)loc != 0)
			goto invalid_relocation;
		val -= (u64)loc;	//   
		*(u32 *)loc = val;  //    
		break;
	.....

الآن يمكننا حساب القيمة النهائية بأنفسنا ، مع العلم أن sym-> st_value هو عنوان دالة printk 0xffffffff810b3df9 ، r_addend هو (-4) ، الإزاحة إلى موضع النقل هي 0xd من بداية قسم نص الوحدة ، أو من بداية وظيفة foo ، أي سيكون ffffffffc000000d. استبدل كل هذه القيم واحصل على:

val = (u32)(0xffffffff810b3df9 - 0x4 - 0xffffffffc000000d) = 0xc10b3de8

دعونا نلقي نظرة على تفريغ وظيفة foo ، التي حصلنا عليها في البداية:

53 89 fe 89 fb 48 c7 c7 24 10 00 c0 e8 e8 3d 0b c1 8d 43 02 5b c3 66 2e 0f

عند الإزاحة 0xD ، تم العثور على القيمة 0xc10b3de8 ، وهي مطابقة للقيمة التي حسبناها.

هذه هي الطريقة التي تعالج بها kernel عمليات الترحيل وتحصل على الإزاحة اللازمة لأمر الإغلاق.

في إعداد المقال ، تم استخدام إصدار نواة لينكس 5.4.27.

All Articles